From b22cdd93eb76cf151c24aeda7430747b011af1a0 Mon Sep 17 00:00:00 2001 From: grigo Date: Wed, 20 May 2026 08:49:14 +0300 Subject: [PATCH] Daily checkup --- __pycache__/ble_gatt.cpython-313.pyc | Bin 0 -> 84467 bytes app/src/main/AndroidManifest.xml | 7 + .../aismap/AisTargetsActivity.java | 26 + .../com/grigowashere/aismap/MainActivity.java | 209 +++- .../aismap/RadarPlotterActivity.java | 321 ++++++ .../grigowashere/aismap/SettingsActivity.java | 183 +++- .../aismap/ble/hub/AisHubGattClient.java | 95 +- .../aismap/controllers/AppCoordinator.java | 207 +++- .../NavigatorCameraController.java | 296 ++++++ .../aismap/maps/MapForgeImpl.java | 78 +- .../aismap/maps/MapInterface.java | 75 +- .../aismap/maps/MapLibreMapImpl.java | 328 ++++++- .../aismap/maps/RadarMapHelper.java | 103 ++ .../aismap/maps/YandexMapImpl.java | 238 ++++- .../settings/InterfacesSettingsActivity.java | 22 + .../aismap/ui/BottomSheetsManager.java | 57 +- .../aismap/ui/UIRenderingCoordinator.java | 71 ++ .../aismap/utils/NavigatorZoomMath.java | 90 ++ .../grigowashere/aismap/utils/RangeMath.java | 72 ++ .../aismap/utils/SettingsManager.java | 183 +++- .../aismap/utils/UiInsetsUtils.java | 34 + .../aismap/view/BaseDockWidget.java | 180 ++-- .../grigowashere/aismap/view/CompassView.java | 126 ++- .../aismap/view/CoordinatesDockWidget.java | 71 +- .../aismap/view/DangerTargetsDockWidget.java | 345 +++++++ .../aismap/view/PlotterHeadingView.java | 147 +++ .../aismap/view/PlotterSpeedometerView.java | 141 +++ .../aismap/view/PlotterTargetsTableView.java | 189 ++++ .../aismap/view/RadarGraticuleOverlay.java | 319 ++++++ .../main/res/drawable/ic_radar_plotter.xml | 20 + app/src/main/res/drawable/ic_signal_off.xml | 20 + .../res/drawable/plotter_bezel_background.xml | 9 + .../res/drawable/plotter_panel_background.xml | 9 + .../drawable/plotter_radar_viewport_bg.xml | 9 + .../layout-port/activity_radar_plotter.xml | 126 +++ .../layout/activity_interfaces_settings.xml | 131 +-- app/src/main/res/layout/activity_main.xml | 108 ++- .../res/layout/activity_radar_plotter.xml | 128 +++ app/src/main/res/layout/activity_settings.xml | 915 +++++++++++------- .../res/layout/bottom_sheet_ais_vessel.xml | 42 +- app/src/main/res/values/colors.xml | 31 +- app/src/main/res/values/strings.xml | 163 +++- app/src/main/res/values/themes.xml | 8 + .../aismap/utils/NavigatorZoomMathTest.java | 59 ++ .../aismap/utils/RangeMathTest.java | 89 ++ ble_gatt.py | 78 +- 46 files changed, 5478 insertions(+), 680 deletions(-) create mode 100644 __pycache__/ble_gatt.cpython-313.pyc create mode 100644 app/src/main/java/com/grigowashere/aismap/RadarPlotterActivity.java create mode 100644 app/src/main/java/com/grigowashere/aismap/controllers/NavigatorCameraController.java create mode 100644 app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java create mode 100644 app/src/main/java/com/grigowashere/aismap/utils/NavigatorZoomMath.java create mode 100644 app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java create mode 100644 app/src/main/java/com/grigowashere/aismap/utils/UiInsetsUtils.java create mode 100644 app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java create mode 100644 app/src/main/java/com/grigowashere/aismap/view/PlotterHeadingView.java create mode 100644 app/src/main/java/com/grigowashere/aismap/view/PlotterSpeedometerView.java create mode 100644 app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java create mode 100644 app/src/main/java/com/grigowashere/aismap/view/RadarGraticuleOverlay.java create mode 100644 app/src/main/res/drawable/ic_radar_plotter.xml create mode 100644 app/src/main/res/drawable/ic_signal_off.xml create mode 100644 app/src/main/res/drawable/plotter_bezel_background.xml create mode 100644 app/src/main/res/drawable/plotter_panel_background.xml create mode 100644 app/src/main/res/drawable/plotter_radar_viewport_bg.xml create mode 100644 app/src/main/res/layout-port/activity_radar_plotter.xml create mode 100644 app/src/main/res/layout/activity_radar_plotter.xml create mode 100644 app/src/test/java/com/grigowashere/aismap/utils/NavigatorZoomMathTest.java create mode 100644 app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java diff --git a/__pycache__/ble_gatt.cpython-313.pyc b/__pycache__/ble_gatt.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..529a1c7b24da2151192ad4facf1a2b293eaca4db GIT binary patch literal 84467 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 + + 0.0) { + java.util.List distanceFiltered = new java.util.ArrayList<>(filtered.size()); + for (AISVesselEntity e : filtered) { + if (e == null) continue; + if (!GeoUtils.isValidCoordinates(e.latitude, e.longitude)) { + distanceFiltered.add(e); + continue; + } + double d = GeoUtils.calculateDistance( + ourLatitude, ourLongitude, e.latitude, e.longitude); + if (d <= maxDistanceM) { + distanceFiltered.add(e); + } + } + filtered = distanceFiltered; + } + } adapter.submitList(filtered); int targetCount = filtered.size(); textTargetCount.setText("AIS цели: " + targetCount); diff --git a/app/src/main/java/com/grigowashere/aismap/MainActivity.java b/app/src/main/java/com/grigowashere/aismap/MainActivity.java index 42d3553..40d714c 100644 --- a/app/src/main/java/com/grigowashere/aismap/MainActivity.java +++ b/app/src/main/java/com/grigowashere/aismap/MainActivity.java @@ -59,8 +59,16 @@ import com.grigowashere.aismap.controllers.ControllersFactory; import com.grigowashere.aismap.controllers.DefaultControllersFactory; public class MainActivity extends AppCompatActivity { + + /** Живой координатор для вторичных экранов (радар), пока MainActivity в стеке. */ + private static volatile AppCoordinator sAppCoordinator; private static final String TAG = "MainActivity"; + + /** @return координатор приложения или {@code null}, если карта ещё не инициализирована */ + public static AppCoordinator getAppCoordinator() { + return sAppCoordinator; + } private static final int PERMISSION_REQUEST_CODE = 1001; private static final int SETTINGS_REQUEST_CODE = 1002; private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1003; @@ -83,14 +91,19 @@ public class MainActivity extends AppCompatActivity { private SettingsManager settingsManager; private ImageButton btnCenterOnVessel; + private ImageButton btnNavigatorFollow; private ImageButton btnMapOrientation; private ImageButton btnCursorToggle; private ImageButton btnSettings; private ImageButton btnAisTargets; + private ImageButton btnRadarPlotter; private ImageButton btnGpsSource; private LinearLayout controlPanel; private CompassView compassView; private CoordinatesDockWidget coordinatesWidget; + private com.grigowashere.aismap.view.DangerTargetsDockWidget dangerWidget; + private android.widget.LinearLayout bannerConnectionLost; + private TextView tvBannerConnectionLost; // Троттлинг для UI обновлений private android.os.Handler uiThrottleHandler; @@ -214,14 +227,19 @@ public class MainActivity extends AppCompatActivity { private void initializeViews() { mapView = findViewById(R.id.map_view); btnCenterOnVessel = findViewById(R.id.btn_center_vessel); + btnNavigatorFollow = findViewById(R.id.btn_navigator_follow); btnMapOrientation = findViewById(R.id.btn_map_orientation); btnCursorToggle = findViewById(R.id.btn_cursor_toggle); btnSettings = findViewById(R.id.btn_settings); btnAisTargets = findViewById(R.id.btn_ais_targets); + btnRadarPlotter = findViewById(R.id.btn_radar_plotter); btnGpsSource = findViewById(R.id.btn_gps_source); controlPanel = findViewById(R.id.control_panel); compassView = findViewById(R.id.compass_view); coordinatesWidget = findViewById(R.id.coordinates_widget); + dangerWidget = findViewById(R.id.danger_targets_widget); + bannerConnectionLost = findViewById(R.id.banner_connection_lost); + tvBannerConnectionLost = findViewById(R.id.tv_banner_connection_lost); installMainUiInsets(); // Инициализируем троттлинг @@ -292,7 +310,12 @@ public class MainActivity extends AppCompatActivity { } private void setupButtonListeners() { - if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel()); + if (btnCenterOnVessel != null) { + btnCenterOnVessel.setOnClickListener(v -> centerOnVessel()); + } + if (btnNavigatorFollow != null) { + btnNavigatorFollow.setOnClickListener(v -> toggleNavigatorCamera()); + } if (btnMapOrientation != null) { btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode()); } @@ -300,6 +323,7 @@ public class MainActivity extends AppCompatActivity { if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; }); if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings()); if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets()); + if (btnRadarPlotter != null) btnRadarPlotter.setOnClickListener(v -> openRadarPlotter()); if (btnGpsSource != null) { refreshGpsSourceButtonIcon(); btnGpsSource.setOnClickListener(v -> toggleGpsSource()); @@ -316,13 +340,16 @@ public class MainActivity extends AppCompatActivity { // Устанавливаем начальный азимут (например, север) compassView.setAzimuth(0); - // Устанавливаем компас в dock-режим вверху экрана + // Компас уже стартует в dock-state=true, dockTop=true (см. + // BaseDockWidget.init() + getDefaultDockTop() по умолчанию). Позиция + // задаётся layout_alignParentTop в activity_main.xml. Раньше тут стоял + // post(() -> setDocked(true, true, 0, 0)) — для top-дока он чаще всего + // был no-op'ом, но для симметрии с координатами оставляем тут только + // переприменение insets и обновление контрол-панели. compassView.post(() -> { - compassView.setDocked(true, true, 0, 0); - compassView.invalidate(); // Принудительная отрисовка - // Выровнять паддинги под статус-бар/вырез камеры сразу после - // первого dock-позиционирования (до этого сторона неизвестна). + compassView.invalidate(); reapplyInsetsToDocks(); + updateControlPanelPosition(); }); // Настраиваем слушатель изменения размера док-виджета @@ -441,15 +468,21 @@ public class MainActivity extends AppCompatActivity { reapplyInsetsToDocks(); }); - // Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных + // Виджет координат уже стартует в dock-state=true, dockTop=false + // (см. CoordinatesDockWidget.getDefaultDockTop() и BaseDockWidget.init()), + // а позиция определяется layout_alignParentBottom в activity_main.xml. + // Раньше тут стоял coordinatesWidget.post(() -> setDocked(true, false, 0, 0)), + // и из-за того, что post-колбэк выполнялся уже после первого layout-а, + // ранний return в setDocked не срабатывал, а calculateDockPosition в + // момент колбэка часто получал parent.getHeight() == 0 → endY уходило + // в отрицательную область, и анимация уезжала translation-ом ВВЕРХ за + // экран. Виджеты «мигали в центре, улетали вверх и появлялись снизу + // только после ресайза». Теперь мы просто переприменяем insets, чтобы + // виджет сразу получил bottom-padding под нав-бар. coordinatesWidget.post(() -> { - Log.d(TAG, "Setting coordinates widget to dock mode"); - coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу - coordinatesWidget.invalidate(); // Принудительная отрисовка - // Только сейчас мы знаем сторону дока (bottom) — переприменяем - // инсеты, чтобы виджет получил bottom padding под нав-бар - // сразу, а не только после первого ресайза пользователем. + coordinatesWidget.invalidate(); reapplyInsetsToDocks(); + updateControlPanelPosition(); }); } @@ -485,6 +518,8 @@ public class MainActivity extends AppCompatActivity { Integer batt = appCoordinator.getLastBleBattery(); tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --"); } + updateBleLinkLostBanner(); + updateDangerWidget(); } } catch (Exception ignored) {} messageAgeHandler.postDelayed(this, 1000); @@ -494,6 +529,86 @@ public class MainActivity extends AppCompatActivity { messageAgeHandler.postDelayed(messageAgeRunnable, 1000); } + /** + * Баннер «нет связи с устройством» — только при потере BLE GATT-сессии с AIS Hub. + * Возраст GPS/AIS здесь не используется: при обрыве линка данным доверять нельзя, + * после reconnect клиент сам запрашивает snapshot. + */ + private void updateBleLinkLostBanner() { + if (bannerConnectionLost == null || tvBannerConnectionLost == null) return; + if (appCoordinator == null) return; + boolean linkLost = appCoordinator.isBleHubLinkLost(); + if (!linkLost) { + if (bannerConnectionLost.getVisibility() != View.GONE) { + bannerConnectionLost.animate().alpha(0f).setDuration(200) + .withEndAction(() -> { + bannerConnectionLost.setVisibility(View.GONE); + onBannerVisibilityChanged(); + }) + .start(); + } + return; + } + tvBannerConnectionLost.setText(R.string.banner_connection_lost_ble); + if (bannerConnectionLost.getVisibility() != View.VISIBLE) { + bannerConnectionLost.setAlpha(0f); + bannerConnectionLost.setVisibility(View.VISIBLE); + onBannerVisibilityChanged(); + bannerConnectionLost.animate().alpha(1f).setDuration(200).start(); + } + } + + /** Пересчёт инсетов и layout после показа/скрытия баннера связи. */ + private void onBannerVisibilityChanged() { + applyInsetsToDocks(lastSysInsets); + View root = findViewById(R.id.main_root); + if (root != null) { + root.requestLayout(); + } + if (compassView != null) { + compassView.requestLayout(); + } + } + + /** + * Обновляет таблицу «Опасные цели» каждую секунду из AppCoordinator. + * Если опасных целей нет (или функция отключена в настройках) — виджет + * полностью скрывается, чтобы не занимать место на карте. + */ + private void updateDangerWidget() { + if (dangerWidget == null || appCoordinator == null || settingsManager == null) return; + boolean enabled = settingsManager.isRangeRingsEnabled(); + double dangerR = enabled ? settingsManager.getDangerRadiusMeters() : 0.0; + java.util.List uiEntries = + new java.util.ArrayList<>(); + if (enabled && dangerR > 0.0) { + java.util.List entries = + appCoordinator.getDangerTargets(dangerR, 5); + if (entries != null) { + for (com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry e : entries) { + if (e == null || e.vessel == null) continue; + String label = e.vessel.getVesselName(); + if (label == null || label.trim().isEmpty()) { + label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—"; + } + uiEntries.add(new com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry( + label, e.bearingDegrees, e.distanceMeters)); + } + } + } + if (uiEntries.isEmpty()) { + if (dangerWidget.getVisibility() != View.GONE) { + dangerWidget.setVisibility(View.GONE); + } + dangerWidget.setEntries(uiEntries); + return; + } + dangerWidget.setEntries(uiEntries); + if (dangerWidget.getVisibility() != View.VISIBLE) { + dangerWidget.setVisibility(View.VISIBLE); + } + } + private int getAgeColor(int seconds) { if (seconds < 0) { // Нет данных @@ -842,6 +957,7 @@ public class MainActivity extends AppCompatActivity { // Инициализация главного координатора ControllersFactory controllersFactory = new DefaultControllersFactory(); appCoordinator = controllersFactory.createAppCoordinator(this); + sAppCoordinator = appCoordinator; // Init UI binders menuBinder = new MenuBinder(appCoordinator, settingsManager, new MenuBinder.MenuActions() { @@ -910,6 +1026,7 @@ public class MainActivity extends AppCompatActivity { } }); refreshMapRotationButtonDescription(); + refreshNavigatorButtonState(); } private void startControllers() { @@ -1007,7 +1124,30 @@ public class MainActivity extends AppCompatActivity { private void centerOnVessel() { appCoordinator.centerOnOwnVessel(); - Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show(); + if (!appCoordinator.isNavigatorCameraEnabled()) { + Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show(); + } + } + + private void toggleNavigatorCamera() { + if (appCoordinator == null) return; + appCoordinator.toggleNavigatorCamera(); + refreshNavigatorButtonState(); + int msg = appCoordinator.isNavigatorCameraEnabled() + ? R.string.main_navigator_on + : R.string.main_navigator_off; + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); + } + + private void refreshNavigatorButtonState() { + if (appCoordinator == null) return; + boolean on = appCoordinator.isNavigatorCameraEnabled(); + if (btnNavigatorFollow != null) { + btnNavigatorFollow.setAlpha(on ? 1f : 0.65f); + btnNavigatorFollow.setSelected(on); + btnNavigatorFollow.setContentDescription(getString( + on ? R.string.main_navigator_on : R.string.main_navigator_button)); + } } private static float normalizeBearingTo360(double deg) { @@ -1085,6 +1225,10 @@ public class MainActivity extends AppCompatActivity { private void applyAutoMapBearingIfNeeded(MapInterface map) { if (settingsManager == null || appCoordinator == null || map == null) return; + // В режиме навигатора bearing сглаживает NavigatorCameraController. + if (appCoordinator.isNavigatorCameraEnabled()) { + return; + } String mode = settingsManager.getMapRotationMode(); if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) { return; @@ -1173,6 +1317,10 @@ public class MainActivity extends AppCompatActivity { startActivity(intent); } + private void openRadarPlotter() { + startActivity(new Intent(this, RadarPlotterActivity.class)); + } + /** * Переключает источник координат между BLE Hub и Android GPS «на лету», * обновляет иконку кнопки и уведомляет AppCoordinator. @@ -1239,9 +1387,9 @@ public class MainActivity extends AppCompatActivity { android.widget.RelativeLayout.LayoutParams lp = (android.widget.RelativeLayout.LayoutParams) rawLp; int dp8 = Math.round(getResources().getDisplayMetrics().density * 8); - int compassH = compassView != null ? compassView.getHeight() : 0; + int compassBottom = compassView != null ? compassView.getBottom() : 0; int coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0; - int newTop = compassH + dp8; + int newTop = compassBottom + dp8; int newBottom = coordsH + dp8; if (lp.topMargin != newTop || lp.bottomMargin != newBottom) { lp.topMargin = newTop; @@ -1261,16 +1409,29 @@ public class MainActivity extends AppCompatActivity { * Боковые паддинги даём всегда (landscape-камеры). */ private void applyInsetsToDocks(Insets sys) { + boolean bannerVisible = bannerConnectionLost != null + && bannerConnectionLost.getVisibility() == View.VISIBLE; + if (bannerConnectionLost != null) { + int bottomPad = Math.round(getResources().getDisplayMetrics().density * 10); + bannerConnectionLost.setPadding(sys.left, sys.top, sys.right, bottomPad); + } if (compassView != null) { boolean top = compassView.isDockTop(); - compassView.setPadding(sys.left, top ? sys.top : 0, - sys.right, top ? 0 : sys.bottom); + // Верхний inset на компасе только когда баннера нет — иначе отступ уже в баннере. + int topPad = top && !bannerVisible ? sys.top : 0; + compassView.setPadding(sys.left, topPad, sys.right, top ? 0 : sys.bottom); } if (coordinatesWidget != null) { boolean top = coordinatesWidget.isDockTop(); coordinatesWidget.setPadding(sys.left, top ? sys.top : 0, sys.right, top ? 0 : sys.bottom); } + // Danger сидит МЕЖДУ компасом и координатами — статус-/нав-бар его + // не касаются, но боковые displayCutout (landscape) могут перекрыть + // текст таблицы. Так что прокидываем только left/right. + if (dangerWidget != null) { + dangerWidget.setPadding(sys.left, 0, sys.right, 0); + } } /** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */ @@ -1429,6 +1590,7 @@ public class MainActivity extends AppCompatActivity { if (mapInterface != null) { // Сначала создаем UI Coordinator uiCoordinator = new UIRenderingCoordinator(mapInterface); + uiCoordinator.setSettingsManager(getApplicationContext(), settingsManager); Log.i(TAG, "UIRenderingCoordinator создан"); // Подписываем UIRenderingCoordinator на изменения MapInterface @@ -1438,6 +1600,8 @@ public class MainActivity extends AppCompatActivity { // Устанавливаем UI Coordinator как notifier для AppCoordinator appCoordinator.setUIDataChangeNotifier(uiCoordinator); Log.i(TAG, "UIDataChangeNotifier установлен в AppCoordinator"); + + appCoordinator.onMapInterfaceReady(mapInterface); // AppCoordinator уже подключен к MapController при инициализации // setMapInterface больше не нужен, так как стратегия карты централизована @@ -1675,7 +1839,10 @@ public class MainActivity extends AppCompatActivity { @Override protected void onDestroy() { super.onDestroy(); - + if (isFinishing()) { + sAppCoordinator = null; + } + // MapLibre lifecycle if (mapView != null) { mapView.onDestroy(); @@ -1832,6 +1999,10 @@ public class MainActivity extends AppCompatActivity { applySettings(); } refreshGpsSourceButtonIcon(); + refreshNavigatorButtonState(); + if (appCoordinator != null) { + appCoordinator.setNavigatorCameraEnabled(settingsManager.isNavigatorCameraEnabled()); + } Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show(); } diff --git a/app/src/main/java/com/grigowashere/aismap/RadarPlotterActivity.java b/app/src/main/java/com/grigowashere/aismap/RadarPlotterActivity.java new file mode 100644 index 0000000..7e5d426 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/RadarPlotterActivity.java @@ -0,0 +1,321 @@ +package com.grigowashere.aismap; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.grigowashere.aismap.controllers.AppCoordinator; +import com.grigowashere.aismap.maps.RadarMapHelper; +import com.grigowashere.aismap.models.Vessel; +import com.grigowashere.aismap.utils.GeoUtils; +import com.grigowashere.aismap.utils.RangeMath; +import com.grigowashere.aismap.utils.SettingsManager; +import com.grigowashere.aismap.utils.UiInsetsUtils; +import com.grigowashere.aismap.view.PlotterHeadingView; +import com.grigowashere.aismap.view.PlotterSpeedometerView; +import com.grigowashere.aismap.view.PlotterTargetsTableView; +import com.grigowashere.aismap.view.RadarGraticuleOverlay; + +import org.maplibre.android.MapLibre; +import org.maplibre.android.maps.MapView; + +import java.util.List; +import java.util.Locale; + +/** + * Альтернативный UI в стиле картплоттера с PPI-радаром поверх картовых тайлов. + * Данные AIS/GPS берутся из {@link MainActivity#getAppCoordinator()}. + */ +public class RadarPlotterActivity extends AppCompatActivity { + + private static final long UPDATE_INTERVAL_MS = 1000L; + private static final int TABLE_LIMIT = 8; + + private View radarContentLayout; + private View radarViewportFrame; + private View radarInstrumentsPanel; + private int lastSquareLayoutContentW = -1; + private int lastSquareLayoutContentH = -1; + + private final ViewTreeObserver.OnGlobalLayoutListener squareViewportLayoutListener = + this::applySquareRadarViewport; + + private AppCoordinator appCoordinator; + private SettingsManager settingsManager; + private RadarMapHelper mapHelper; + private MapView mapView; + private RadarGraticuleOverlay graticuleOverlay; + private PlotterHeadingView headingView; + private PlotterSpeedometerView speedometerView; + private PlotterTargetsTableView targetsTableView; + private TextView tvRange; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable updateRunnable = this::tickUi; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + appCoordinator = MainActivity.getAppCoordinator(); + if (appCoordinator == null) { + Toast.makeText(this, R.string.radar_plotter_no_coordinator, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + try { + MapLibre.getInstance(getApplicationContext()); + } catch (Exception ignore) { } + + setContentView(R.layout.activity_radar_plotter); + settingsManager = new SettingsManager(this); + + mapView = findViewById(R.id.radar_map_view); + graticuleOverlay = findViewById(R.id.radar_graticule); + headingView = findViewById(R.id.plotter_heading); + speedometerView = findViewById(R.id.plotter_speedometer); + targetsTableView = findViewById(R.id.plotter_targets_table); + tvRange = findViewById(R.id.tv_radar_range); + + ImageButton btnBack = findViewById(R.id.btn_radar_back); + if (btnBack != null) { + btnBack.setOnClickListener(v -> finish()); + } + + if (mapView != null) { + mapView.setAlpha(0.58f); + } + + if (graticuleOverlay != null) { + graticuleOverlay.setRangeUnit(settingsManager.getRangeUnit()); + } + + mapHelper = new RadarMapHelper(mapView); + mapHelper.initialize(() -> handler.post(this::tickUi)); + + applyPlotterInsets(); + setupSquareRadarViewport(); + } + + /** + * Inscribes the PPI viewport in a square (max side = min(contentW, contentH)) so tall phones + * leave more room for compass, speedometer, and targets table. + */ + private void setupSquareRadarViewport() { + radarViewportFrame = findViewById(R.id.radar_viewport_frame); + radarInstrumentsPanel = findViewById(R.id.radar_instruments_panel); + radarContentLayout = findViewById(R.id.radar_plotter_content); + if (radarContentLayout == null || radarViewportFrame == null || radarInstrumentsPanel == null) { + return; + } + radarContentLayout.getViewTreeObserver().addOnGlobalLayoutListener(squareViewportLayoutListener); + } + + private void applySquareRadarViewport() { + if (radarContentLayout == null || radarViewportFrame == null || radarInstrumentsPanel == null) { + return; + } + + int contentW = radarContentLayout.getWidth() + - radarContentLayout.getPaddingLeft() - radarContentLayout.getPaddingRight(); + int contentH = radarContentLayout.getHeight() + - radarContentLayout.getPaddingTop() - radarContentLayout.getPaddingBottom(); + if (contentW <= 0 || contentH <= 0) { + return; + } + if (contentW == lastSquareLayoutContentW && contentH == lastSquareLayoutContentH) { + return; + } + lastSquareLayoutContentW = contentW; + lastSquareLayoutContentH = contentH; + + int squareSize = Math.min(contentW, contentH); + + ViewGroup.LayoutParams vpRaw = radarViewportFrame.getLayoutParams(); + ViewGroup.LayoutParams panelRaw = radarInstrumentsPanel.getLayoutParams(); + if (!(vpRaw instanceof LinearLayout.LayoutParams) || !(panelRaw instanceof LinearLayout.LayoutParams)) { + return; + } + if (!(radarContentLayout instanceof LinearLayout)) { + return; + } + + LinearLayout content = (LinearLayout) radarContentLayout; + LinearLayout.LayoutParams vpLp = (LinearLayout.LayoutParams) vpRaw; + LinearLayout.LayoutParams panelLp = (LinearLayout.LayoutParams) panelRaw; + boolean vertical = content.getOrientation() == LinearLayout.VERTICAL; + + if (vertical) { + if (vpLp.width == ViewGroup.LayoutParams.MATCH_PARENT + && vpLp.height == squareSize + && vpLp.weight == 0f + && panelLp.height == 0 + && panelLp.weight == 1f) { + return; + } + vpLp.width = ViewGroup.LayoutParams.MATCH_PARENT; + vpLp.height = squareSize; + vpLp.weight = 0f; + panelLp.width = ViewGroup.LayoutParams.MATCH_PARENT; + panelLp.height = 0; + panelLp.weight = 1f; + } else { + if (vpLp.width == squareSize + && vpLp.height == ViewGroup.LayoutParams.MATCH_PARENT + && vpLp.weight == 0f + && panelLp.width == 0 + && panelLp.weight == 1f) { + return; + } + vpLp.width = squareSize; + vpLp.height = ViewGroup.LayoutParams.MATCH_PARENT; + vpLp.weight = 0f; + panelLp.width = 0; + panelLp.height = ViewGroup.LayoutParams.MATCH_PARENT; + panelLp.weight = 1f; + } + + radarViewportFrame.setLayoutParams(vpLp); + radarInstrumentsPanel.setLayoutParams(panelLp); + } + + private void applyPlotterInsets() { + View panel = findViewById(R.id.radar_instruments_panel); + if (panel != null) { + int pad = Math.round(getResources().getDisplayMetrics().density * 8); + UiInsetsUtils.applySystemBarPadding(panel, pad); + } + } + + private void tickUi() { + if (appCoordinator == null) return; + + double ppiRangeM = resolvePpiRangeMeters(); + double dangerM = settingsManager.isRangeRingsEnabled() + ? settingsManager.getDangerRadiusMeters() : 0.0; + + if (tvRange != null) { + tvRange.setText(getString(R.string.radar_plotter_range_label) + ": " + + formatRangeLabel(ppiRangeM)); + } + if (graticuleOverlay != null) { + graticuleOverlay.setRangeMeters(ppiRangeM); + graticuleOverlay.setRangeUnit(settingsManager.getRangeUnit()); + } + + Vessel own = appCoordinator.getOwnVessel(); + float heading = 0f; + double speedKn = 0.0; + if (own != null) { + heading = (float) (own.getCourse() > 0 ? own.getCourse() : own.getHeading()); + speedKn = own.getSpeed(); + if (graticuleOverlay != null) { + graticuleOverlay.setHeadingUpDeg(heading); + } + if (headingView != null) { + float mag = (float) own.getMagneticCompass(); + headingView.setHeading(heading, mag > 0 ? mag : Float.NaN); + } + if (speedometerView != null) { + speedometerView.setSpeedKnots(speedKn); + } + if (mapHelper != null && GeoUtils.isValidCoordinates(own.getLatitude(), own.getLongitude())) { + mapHelper.centerOnOwnShip(own.getLatitude(), own.getLongitude(), heading, ppiRangeM); + } + } + + List nearest = + appCoordinator.getDangerTargets(ppiRangeM, TABLE_LIMIT); + if (graticuleOverlay != null) { + graticuleOverlay.setAllTargetsInRange(nearest, dangerM); + } + if (targetsTableView != null) { + targetsTableView.setRowsFromCoordinatorEntries(nearest); + } + + handler.removeCallbacks(updateRunnable); + handler.postDelayed(updateRunnable, UPDATE_INTERVAL_MS); + } + + private double resolvePpiRangeMeters() { + if (settingsManager.isRangeFilterEnabled()) { + double f = settingsManager.getFilterRadiusMeters(); + if (f > 0) return f; + } + if (settingsManager.isRangeRingsEnabled()) { + double w = settingsManager.getWarningRadiusMeters(); + if (w > 0) return w; + } + return RangeMath.toMeters(5.0, settingsManager.getRangeUnit()); + } + + private String formatRangeLabel(double meters) { + if (SettingsManager.RANGE_UNIT_KM.equals(settingsManager.getRangeUnit())) { + if (meters >= 1000.0) { + return String.format(Locale.US, "%.1f km", meters / 1000.0); + } + return String.format(Locale.US, "%.0f m", meters); + } + return String.format(Locale.US, "%.1f nm", meters / RangeMath.METERS_PER_NM); + } + + @Override + protected void onStart() { + super.onStart(); + if (mapHelper != null) mapHelper.onStart(); + } + + @Override + protected void onResume() { + super.onResume(); + if (mapHelper != null) mapHelper.onResume(); + handler.post(updateRunnable); + } + + @Override + protected void onPause() { + handler.removeCallbacks(updateRunnable); + if (mapHelper != null) mapHelper.onPause(); + super.onPause(); + } + + @Override + protected void onStop() { + if (mapHelper != null) mapHelper.onStop(); + super.onStop(); + } + + @Override + protected void onDestroy() { + handler.removeCallbacks(updateRunnable); + if (radarContentLayout != null) { + radarContentLayout.getViewTreeObserver() + .removeOnGlobalLayoutListener(squareViewportLayoutListener); + } + if (mapHelper != null) mapHelper.onDestroy(); + super.onDestroy(); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (mapHelper != null) mapHelper.onSaveInstanceState(outState); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + if (mapHelper != null) mapHelper.onLowMemory(); + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java index 8a8a4a8..fbbbe26 100644 --- a/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java +++ b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java @@ -16,6 +16,7 @@ import com.google.android.material.switchmaterial.SwitchMaterial; import androidx.appcompat.app.AppCompatActivity; import com.grigowashere.aismap.utils.SettingsManager; +import com.grigowashere.aismap.utils.UiInsetsUtils; /** * Экран настроек приложения @@ -54,6 +55,22 @@ public class SettingsActivity extends AppCompatActivity { private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces; private EditText etOpenInterfaces; + // Range rings + private SwitchMaterial switchRangeRingsEnabled; + private SwitchMaterial switchRangeFilterEnabled; + private RadioGroup radioGroupRangeUnit; + private RadioButton radioRangeUnitNm; + private RadioButton radioRangeUnitKm; + private EditText etRangeDanger; + private EditText etRangeWarning; + private EditText etRangeFilter; + + // Navigator camera + private SwitchMaterial switchNavigatorCameraEnabled; + private EditText etNavigatorMaxSpeed; + private EditText etNavigatorZoomZero; + private EditText etNavigatorZoomMax; + // Path/prediction private EditText etPathMaxPoints; private EditText etPathWidth; @@ -79,7 +96,8 @@ public class SettingsActivity extends AppCompatActivity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); - + applySettingsInsets(); + // Инициализируем менеджер настроек settingsManager = new SettingsManager(this); @@ -97,6 +115,14 @@ public class SettingsActivity extends AppCompatActivity { Log.i(TAG, "SettingsActivity создан"); } + + private void applySettingsInsets() { + View scroll = findViewById(R.id.settings_scroll); + if (scroll != null) { + int pad = Math.round(getResources().getDisplayMetrics().density * 16); + UiInsetsUtils.applySystemBarPadding(scroll, pad); + } + } /** * Инициализирует UI элементы @@ -136,6 +162,23 @@ public class SettingsActivity extends AppCompatActivity { etPredictionWidth = findViewById(R.id.et_prediction_width); etPredictionColor = findViewById(R.id.et_prediction_color); etPredictionHorizon = findViewById(R.id.et_prediction_horizon_sec); + + // Range rings + switchRangeRingsEnabled = findViewById(R.id.switch_range_rings_enabled); + switchRangeFilterEnabled = findViewById(R.id.switch_range_filter_enabled); + radioGroupRangeUnit = findViewById(R.id.radio_group_range_unit); + radioRangeUnitNm = findViewById(R.id.radio_range_unit_nm); + radioRangeUnitKm = findViewById(R.id.radio_range_unit_km); + etRangeDanger = findViewById(R.id.et_range_danger); + etRangeWarning = findViewById(R.id.et_range_warning); + etRangeFilter = findViewById(R.id.et_range_filter); + + switchNavigatorCameraEnabled = findViewById(R.id.switch_navigator_camera_enabled); + etNavigatorMaxSpeed = findViewById(R.id.et_navigator_max_speed); + etNavigatorZoomZero = findViewById(R.id.et_navigator_zoom_zero); + etNavigatorZoomMax = findViewById(R.id.et_navigator_zoom_max); + + // Connection thresholds } /** @@ -195,7 +238,38 @@ public class SettingsActivity extends AppCompatActivity { etPredictionWidth.setText(String.valueOf(settingsManager.getPredictionWidth())); etPredictionColor.setText(String.format("#%06X", (0xFFFFFF & settingsManager.getPredictionColor()))); etPredictionHorizon.setText(String.valueOf(settingsManager.getPredictionHorizonSec())); - + + // Кольца дальности + if (switchRangeRingsEnabled != null) { + switchRangeRingsEnabled.setChecked(settingsManager.isRangeRingsEnabled()); + } + if (switchRangeFilterEnabled != null) { + switchRangeFilterEnabled.setChecked(settingsManager.isRangeFilterEnabled()); + } + if (radioRangeUnitNm != null && radioRangeUnitKm != null) { + if (SettingsManager.RANGE_UNIT_KM.equals(settingsManager.getRangeUnit())) { + radioRangeUnitKm.setChecked(true); + } else { + radioRangeUnitNm.setChecked(true); + } + } + if (etRangeDanger != null) etRangeDanger.setText(String.valueOf(settingsManager.getRangeDanger())); + if (etRangeWarning != null) etRangeWarning.setText(String.valueOf(settingsManager.getRangeWarning())); + if (etRangeFilter != null) etRangeFilter.setText(String.valueOf(settingsManager.getRangeFilter())); + + if (switchNavigatorCameraEnabled != null) { + switchNavigatorCameraEnabled.setChecked(settingsManager.isNavigatorCameraEnabled()); + } + if (etNavigatorMaxSpeed != null) { + etNavigatorMaxSpeed.setText(String.valueOf(settingsManager.getNavigatorMaxSpeedKnots())); + } + if (etNavigatorZoomZero != null) { + etNavigatorZoomZero.setText(String.valueOf(settingsManager.getNavigatorZoomAtZeroSpeed())); + } + if (etNavigatorZoomMax != null) { + etNavigatorZoomMax.setText(String.valueOf(settingsManager.getNavigatorZoomAtMaxSpeed())); + } + Log.i(TAG, "Настройки загружены в UI"); } @@ -397,7 +471,16 @@ public class SettingsActivity extends AppCompatActivity { try { settingsManager.setPredictionWidth(Float.parseFloat(etPredictionWidth.getText().toString().trim())); } catch (Exception ignored) {} try { settingsManager.setPredictionColor(parseColor(etPredictionColor.getText().toString().trim(), settingsManager.getPredictionColor())); } catch (Exception ignored) {} try { settingsManager.setPredictionHorizonSec(Integer.parseInt(etPredictionHorizon.getText().toString().trim())); } catch (Exception ignored) {} - + + // Кольца дальности + if (!saveRangeRingsSettings()) { + return; + } + + if (!saveNavigatorCameraSettings()) { + return; + } + Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary()); // Проверяем, нужно ли уведомить MainActivity об изменениях @@ -525,6 +608,100 @@ public class SettingsActivity extends AppCompatActivity { settingsManager.shouldRestartNMEA(originalAndroidNMEAEnabled, originalUDPNMEAEnabled, originalDataMode); } + /** + * Сохраняет настройки колец дальности с валидацией danger < warning < filter. + * Возвращает {@code false}, если данные некорректны, и не закрывает экран. + */ + private boolean saveRangeRingsSettings() { + if (etRangeDanger == null || etRangeWarning == null || etRangeFilter == null) { + return true; + } + try { + float danger = parseRange(etRangeDanger.getText().toString().trim(), settingsManager.getRangeDanger()); + float warning = parseRange(etRangeWarning.getText().toString().trim(), settingsManager.getRangeWarning()); + float filter = parseRange(etRangeFilter.getText().toString().trim(), settingsManager.getRangeFilter()); + if (danger <= 0f || warning <= 0f || filter <= 0f) { + Toast.makeText(this, R.string.settings_range_validation_positive, Toast.LENGTH_LONG).show(); + return false; + } + if (!(danger < warning && warning < filter)) { + Toast.makeText(this, R.string.settings_range_validation_order, Toast.LENGTH_LONG).show(); + return false; + } + + String unit = SettingsManager.RANGE_UNIT_NM; + if (radioGroupRangeUnit != null) { + int checked = radioGroupRangeUnit.getCheckedRadioButtonId(); + if (checked == R.id.radio_range_unit_km) { + unit = SettingsManager.RANGE_UNIT_KM; + } + } + + settingsManager.setRangeUnit(unit); + settingsManager.setRangeDanger(danger); + settingsManager.setRangeWarning(warning); + settingsManager.setRangeFilter(filter); + if (switchRangeRingsEnabled != null) { + settingsManager.setRangeRingsEnabled(switchRangeRingsEnabled.isChecked()); + } + if (switchRangeFilterEnabled != null) { + settingsManager.setRangeFilterEnabled(switchRangeFilterEnabled.isChecked()); + } + return true; + } catch (Exception e) { + Log.w(TAG, "saveRangeRingsSettings: " + e.getMessage(), e); + return true; // фейл валидации не должен блокировать всё сохранение + } + } + + private float parseRange(String text, float fallback) { + try { + if (text == null || text.isEmpty()) return fallback; + return Float.parseFloat(text.replace(',', '.')); + } catch (Exception e) { + return fallback; + } + } + + private boolean saveNavigatorCameraSettings() { + try { + if (switchNavigatorCameraEnabled != null) { + settingsManager.setNavigatorCameraEnabled(switchNavigatorCameraEnabled.isChecked()); + } + if (etNavigatorMaxSpeed != null) { + float maxSpeed = parseRange( + etNavigatorMaxSpeed.getText().toString().trim(), + settingsManager.getNavigatorMaxSpeedKnots()); + if (maxSpeed < 1f) { + Toast.makeText(this, "Макс. скорость должна быть не меньше 1 уз", Toast.LENGTH_SHORT).show(); + return false; + } + settingsManager.setNavigatorMaxSpeedKnots(maxSpeed); + } + if (etNavigatorZoomZero != null) { + settingsManager.setNavigatorZoomAtZeroSpeed(parseRange( + etNavigatorZoomZero.getText().toString().trim(), + settingsManager.getNavigatorZoomAtZeroSpeed())); + } + if (etNavigatorZoomMax != null) { + float zoomMax = parseRange( + etNavigatorZoomMax.getText().toString().trim(), + settingsManager.getNavigatorZoomAtMaxSpeed()); + float zoomZero = settingsManager.getNavigatorZoomAtZeroSpeed(); + if (zoomMax >= zoomZero) { + Toast.makeText(this, "Зум при макс. скорости должен быть меньше зума при 0 уз", + Toast.LENGTH_LONG).show(); + return false; + } + settingsManager.setNavigatorZoomAtMaxSpeed(zoomMax); + } + return true; + } catch (Exception e) { + Log.w(TAG, "saveNavigatorCameraSettings: " + e.getMessage(), e); + return true; + } + } + /** * Очищает трекер пути собственного судна */ diff --git a/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java b/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java index 2dc1f2f..827e56b 100644 --- a/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java +++ b/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java @@ -104,6 +104,11 @@ public class AisHubGattClient { private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L; private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L; + /** Чтение Battery (0x180F/0x2A19) включено пользователем. По умолчанию off. */ + private volatile boolean batteryReadEnabled = false; + /** Залогировали один раз, чтобы не спамить, если стек продолжает возвращать auth-error. */ + private volatile boolean authWarningLogged = false; + public AisHubGattClient(@NonNull Context context) { this.appContext = context.getApplicationContext(); BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE); @@ -118,10 +123,32 @@ public class AisHubGattClient { this.deviceMac = mac; } + /** + * Включает/выключает периодическое чтение характеристики Battery + * ({@code 0x180F/0x2A19}). На некоторых устройствах эта характеристика + * требует шифрования, что приводит к появлению системного диалога + * сопряжения. По умолчанию выключено. + */ + public void setBatteryReadEnabled(boolean enabled) { + this.batteryReadEnabled = enabled; + if (!enabled) { + batteryLoop.set(false); + if (batteryTask != null) { + try { batteryTask.cancel(false); } catch (Throwable ignore) {} + batteryTask = null; + } + } + } + public boolean isRunning() { return running.get(); } + /** {@code true}, если GATT-сессия в состоянии {@link BluetoothProfile#STATE_CONNECTED}. */ + public boolean isConnected() { + return connected; + } + public void start() { if (running.get()) { Log.w(TAG, "AIS Hub GATT already running"); @@ -175,7 +202,12 @@ public class AisHubGattClient { public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { if (!running.get()) return; if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState); - if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) { + if (isAuthStatus(status)) { + logAuthOnce("onConnectionStateChange status=" + status); + // ВАЖНО: BLE — основной источник данных. Auth-статус мы НЕ считаем + // фатальным: продолжаем reconnect-цикл, а проблемные операции + // (read battery и т.п.) сами по себе уже отключены/опциональны. + } else if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) { postError("BLE connect status: " + status); } else if (status == 133) { lastErrorWasDbFull = true; @@ -197,6 +229,7 @@ public class AisHubGattClient { connectionStartTimeMs = 0L; isConnecting.set(false); lastErrorWasDbFull = false; + authWarningLogged = false; reconnectLoop.set(false); notifReady.set(false); mtuRequested.set(false); @@ -314,13 +347,24 @@ public class AisHubGattClient { public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) { gattBusy.set(false); if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st); + if (isAuthStatus(st)) { + // Не валим соединение и не показываем плашку — просто логируем + // один раз и пропускаем эту необязательную операцию (CCCD + // на доп.характеристиках, например STATUS). + logAuthOnce("onDescriptorWrite status=" + st + " uuid=" + descriptor.getUuid()); + return; + } if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) { notifReady.set(true); lastDataAtMs = System.currentTimeMillis(); postState("notifying"); - try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {} - readBatteryOnce(g); - startBatteryLoop(g); + if (batteryReadEnabled) { + try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {} + readBatteryOnce(g); + startBatteryLoop(g); + } else if (BLE_LOG) { + Log.d(TAG, "Battery read disabled by settings (skipping resolve/read/loop)"); + } enqueueControlJson(buildHello()); // Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw), @@ -358,6 +402,17 @@ public class AisHubGattClient { @Override public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) { + if (isAuthStatus(status)) { + gattBusy.set(false); + logAuthOnce("onCharacteristicRead status=" + status + " uuid=" + ch.getUuid()); + // Если это была попытка чтения Battery — на всякий случай гасим + // её, чтобы не провоцировать повторный системный диалог. + if (BATTERY_LEVEL.equals(ch.getUuid()) + || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) { + setBatteryReadEnabled(false); + } + return; + } if (status == BluetoothGatt.GATT_SUCCESS) { if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) { byte[] v = ch.getValue(); @@ -371,6 +426,13 @@ public class AisHubGattClient { @Override public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) { + if (isAuthStatus(status)) { + gattBusy.set(false); + logAuthOnce("onCharacteristicWrite status=" + status + " uuid=" + ch.getUuid()); + // Просто допускаем дренаж очереди дальше — соединение НЕ рвём. + mainHandler.post(AisHubGattClient.this::drainControlQueue); + return; + } if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) { gattBusy.set(false); if (status != BluetoothGatt.GATT_SUCCESS) { @@ -778,6 +840,31 @@ public class AisHubGattClient { }, 2000, 10_000, TimeUnit.MILLISECONDS); } + /** + * Возвращает {@code true} для GATT-статусов, требующих сопряжения: + *
    + *
  • {@code 5} — {@code GATT_INSUF_AUTHENTICATION}
  • + *
  • {@code 8} — {@code GATT_INSUF_ENCRYPTION}
  • + *
  • {@code 137} — {@code GATT_AUTH_FAIL}
  • + *
+ */ + private static boolean isAuthStatus(int status) { + return status == 5 || status == 8 || status == 137; + } + + /** + * Лог auth-status. Один раз пишем в логи, чтобы не спамить, но соединение + * НЕ рвём и reconnect-loop не глушим — BLE является основным источником + * данных, отключать его по auth-ошибке нельзя. Само сопряжение — это + * системный диалог Android, который пользователь может проигнорировать. + */ + private void logAuthOnce(String reason) { + if (authWarningLogged) return; + authWarningLogged = true; + Log.w(TAG, "BLE auth status (suppressed, connection kept): " + reason); + LogSender.logBLEError("auth status (suppressed): " + reason, deviceMac, "AisHub"); + } + private void postState(String s) { if (callback != null) { mainHandler.post(() -> callback.onState(s)); diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java b/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java index 7e3ebff..58149b1 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java @@ -48,6 +48,7 @@ public class AppCoordinator implements private NotificationController notificationController; private CompassController compassController; private MapController mapController; + private NavigatorCameraController navigatorCameraController; private AisHubGattClient aisHubGattClient; // Состояние приложения @@ -108,6 +109,7 @@ public class AppCoordinator implements this.aisPathControllers = new HashMap<>(); this.settingsManager = new SettingsManager(context); this.pathController = new VesselPathController(context, settingsManager); + this.navigatorCameraController = new NavigatorCameraController(settingsManager); this.uiHandler = new Handler(Looper.getMainLooper()); initializeControllers(); @@ -193,6 +195,39 @@ public class AppCoordinator implements if (mapController != null) { mapController.addMapInterfaceChangeListener(this); Log.i(TAG, "AppCoordinator подключен к MapController"); + syncNavigatorMapInterface(); + } + } + + public NavigatorCameraController getNavigatorCameraController() { + return navigatorCameraController; + } + + public boolean isNavigatorCameraEnabled() { + return navigatorCameraController != null && navigatorCameraController.isEnabled(); + } + + public void setNavigatorCameraEnabled(boolean enabled) { + if (navigatorCameraController == null) return; + navigatorCameraController.setEnabled(enabled); + if (enabled) { + syncNavigatorMapInterface(); + updateNavigatorCamera(); + } + } + + public void toggleNavigatorCamera() { + setNavigatorCameraEnabled(!isNavigatorCameraEnabled()); + } + + /** + * Вызывается из MainActivity после инициализации карты — подключает навигатор к MapInterface. + */ + public void onMapInterfaceReady(MapInterface mapInterface) { + if (navigatorCameraController == null) return; + navigatorCameraController.setMapInterface(mapInterface); + if (navigatorCameraController.isEnabled()) { + updateNavigatorCamera(); } } @@ -216,7 +251,12 @@ public class AppCoordinator implements networkController.startUDPListener(); compassController.startCompass(); dataController.startDatabaseCleanup(); - // BLE старт по настройкам + // BLE: применяем настройки (MAC + batteryRead) до старта клиента, + // иначе клиент стартует «голым» и любые опциональные операции + // (например, чтение Battery 0x2A19) могут спровоцировать системный + // диалог сопряжения. Конфигурация ставит batteryReadEnabled=false + // по умолчанию — это убирает основной триггер pairing. + configureBleFromSettings(); tryStartBleIfEnabled(); // Восстанавливаем данные из БД @@ -266,7 +306,9 @@ public class AppCoordinator implements if (aisHubGattClient == null) return; String mac = settingsManager.getBLEDeviceMac(); aisHubGattClient.setDeviceMac(mac); - Log.i(TAG, "BLE AIS Hub: mac=" + mac); + boolean batteryEnabled = settingsManager.isBleReadBatteryEnabled(); + aisHubGattClient.setBatteryReadEnabled(batteryEnabled); + Log.i(TAG, "BLE AIS Hub: mac=" + mac + " batteryRead=" + batteryEnabled); } private void tryStartBleIfEnabled() { @@ -476,16 +518,56 @@ public class AppCoordinator implements return; } final List copy = new ArrayList<>(vessels); - for (int start = 0; start < copy.size(); start += AIS_UI_BATCH_SIZE) { + // Если включён фильтр-круг — отбрасываем цели за его пределами и + // одновременно «снимаем» их с карты (publishAisRemovalsToUiBatched). + final boolean filterEnabled = settingsManager != null + && settingsManager.isRangeFilterEnabled(); + final double filterRadiusM = filterEnabled + ? settingsManager.getFilterRadiusMeters() + : Double.POSITIVE_INFINITY; + final boolean ownValid = ownVessel != null + && com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates( + ownVessel.getLatitude(), ownVessel.getLongitude()); + final List inRange; + final List filteredOutMmsis; + if (filterEnabled && ownValid && filterRadiusM > 0.0 + && filterRadiusM != Double.POSITIVE_INFINITY) { + inRange = new ArrayList<>(copy.size()); + filteredOutMmsis = new ArrayList<>(); + for (AISVessel v : copy) { + if (v == null) continue; + if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates( + v.getLatitude(), v.getLongitude())) { + inRange.add(v); + continue; + } + double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance( + ownVessel.getLatitude(), ownVessel.getLongitude(), + v.getLatitude(), v.getLongitude()); + if (d <= filterRadiusM) { + inRange.add(v); + } else if (v.getMmsi() != null) { + filteredOutMmsis.add(v.getMmsi()); + } + } + } else { + inRange = copy; + filteredOutMmsis = null; + } + + for (int start = 0; start < inRange.size(); start += AIS_UI_BATCH_SIZE) { final int from = start; - final int to = Math.min(start + AIS_UI_BATCH_SIZE, copy.size()); + final int to = Math.min(start + AIS_UI_BATCH_SIZE, inRange.size()); uiHandler.post(() -> { if (uiDataNotifier == null) return; for (int i = from; i < to; i++) { - uiDataNotifier.onAISVesselChanged(copy.get(i)); + uiDataNotifier.onAISVesselChanged(inRange.get(i)); } }); } + if (filteredOutMmsis != null && !filteredOutMmsis.isEmpty()) { + publishAisRemovalsToUiBatched(filteredOutMmsis); + } } private void publishAisRemovalsToUiBatched(List mmsis) { @@ -559,6 +641,8 @@ public class AppCoordinator implements " mode=" + settingsManager.getDataMode()); } + updateNavigatorCamera(); + // Важно: ownship.update может приходить очень часто (десятки раз в секунду). // Модель обновляем всегда, а тяжёлые операции (путь/БД/UI) — с throttling, // чтобы не забивать главный поток и не провоцировать нестабильность BLE. @@ -674,6 +758,7 @@ public class AppCoordinator implements ownVessel.setActiveSatellites(vessel.getActiveSatellites()); markRecentGpsActivity(); + updateNavigatorCamera(); if (pathController != null && isValidCoordinates(ownVessel.getLatitude(), ownVessel.getLongitude())) { pathController.addPathPoint( ownVessel.getLongitude(), @@ -729,6 +814,7 @@ public class AppCoordinator implements // Обновляем компас после изменения курса updateCompass(); + updateNavigatorCamera(); // Сохраняем в БД dataController.saveVesselPosition(ownVessel); @@ -1055,7 +1141,8 @@ public class AppCoordinator implements // Устанавливаем MarkerClickListener на новую карту newMapInterface.setMarkerClickListener(this); Log.i(TAG, "MarkerClickListener установлен на новую карту"); - + syncNavigatorMapInterface(); + updateNavigatorCamera(); // Восстанавливаем состояние на новой карте restoreMapStateOnNewInterface(); } @@ -1167,6 +1254,65 @@ public class AppCoordinator implements return nearby; } + + /** + * Контейнер «цель + дистанция/пеленг до собственного судна». + * Используется виджетом ближайших целей в зоне опасности. + */ + public static final class DangerEntry { + public final AISVessel vessel; + /** Дистанция в метрах. */ + public final double distanceMeters; + /** Пеленг от собственного судна на цель, °. */ + public final double bearingDegrees; + + public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees) { + this.vessel = vessel; + this.distanceMeters = distanceMeters; + this.bearingDegrees = bearingDegrees; + } + } + + /** + * Возвращает цели в зоне опасности (по {@code maxRadiusMeters}) с + * дистанцией и пеленгом, отсортированные по возрастанию дистанции. + * Если собственная позиция неизвестна — возвращает пустой список. + * + * @param maxRadiusMeters максимальный радиус, м + * @param limit максимальное число записей (>=1) + */ + public List getDangerTargets(double maxRadiusMeters, int limit) { + List result = new ArrayList<>(); + if (!(maxRadiusMeters > 0.0)) return result; + if (ownVessel == null) return result; + double oLat = ownVessel.getLatitude(); + double oLon = ownVessel.getLongitude(); + if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(oLat, oLon)) return result; + + synchronized (aisVessels) { + for (AISVessel vessel : aisVessels.values()) { + if (vessel == null) continue; + double lat = vessel.getLatitude(); + double lon = vessel.getLongitude(); + if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(lat, lon)) continue; + double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(oLat, oLon, lat, lon); + if (d <= maxRadiusMeters) { + double b = com.grigowashere.aismap.utils.GeoUtils.calculateBearing(oLat, oLon, lat, lon); + result.add(new DangerEntry(vessel, d, b)); + } + } + } + java.util.Collections.sort(result, (a, b) -> Double.compare(a.distanceMeters, b.distanceMeters)); + if (limit > 0 && result.size() > limit) { + return new ArrayList<>(result.subList(0, limit)); + } + return result; + } + + /** Возвращает {@code ownVessel} (может быть {@code null} до первой фиксации). */ + public Vessel getOwnVesselSnapshot() { + return ownVessel; + } private void updateCompass() { if (listener != null) { @@ -1347,21 +1493,52 @@ public class AppCoordinator implements public Integer getLastBleBattery() { return lastBleBattery; } + + /** + * {@code true}, если BLE включён, MAC задан, клиент запущен, но GATT не подключён + * (в т.ч. идёт переподключение). В этом режиме данным с хаба доверять нельзя. + */ + public boolean isBleHubLinkLost() { + if (!settingsManager.isBLEEnabled()) return false; + String mac = settingsManager.getBLEDeviceMac(); + if (mac == null || mac.trim().isEmpty()) return false; + if (aisHubGattClient == null || !aisHubGattClient.isRunning()) return false; + return !aisHubGattClient.isConnected(); + } /** * Центрирует карту на позиции нашего судна */ public void centerOnOwnVessel() { - if (ownVessel != null) { - Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude()); - - // Уведомляем UI Coordinator о необходимости центрирования карты - if (uiDataNotifier != null) { - uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude()); - } else { - Log.w(TAG, "uiDataNotifier не установлен, центрирование карты пропущено"); - } + if (ownVessel == null) return; + Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude()); + + syncNavigatorMapInterface(); + if (navigatorCameraController != null && navigatorCameraController.isEnabled()) { + navigatorCameraController.onOwnVesselUpdated(ownVessel); + return; } + if (navigatorCameraController != null && mapController != null + && mapController.getCurrentMapInterface() != null) { + navigatorCameraController.centerOnOwnVesselNow(ownVessel); + return; + } + if (uiDataNotifier != null) { + uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude()); + } else { + Log.w(TAG, "uiDataNotifier не установлен, центрирование карты пропущено"); + } + } + + private void syncNavigatorMapInterface() { + if (navigatorCameraController == null || mapController == null) return; + navigatorCameraController.setMapInterface(mapController.getCurrentMapInterface()); + } + + private void updateNavigatorCamera() { + if (navigatorCameraController == null || !navigatorCameraController.isEnabled()) return; + syncNavigatorMapInterface(); + navigatorCameraController.onOwnVesselUpdated(ownVessel); } /** diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/NavigatorCameraController.java b/app/src/main/java/com/grigowashere/aismap/controllers/NavigatorCameraController.java new file mode 100644 index 0000000..2773755 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/controllers/NavigatorCameraController.java @@ -0,0 +1,296 @@ +package com.grigowashere.aismap.controllers; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.util.Log; + +import com.grigowashere.aismap.maps.MapInterface; +import com.grigowashere.aismap.models.Vessel; +import com.grigowashere.aismap.utils.GeoUtils; +import com.grigowashere.aismap.utils.NavigatorZoomMath; +import com.grigowashere.aismap.utils.SettingsManager; + +/** + * Следит за собственным судном: центр карты на позиции, зум от скорости, + * плавные переходы камеры и bearing (если включён режим компас/курс). + */ +public class NavigatorCameraController { + + private static final String TAG = "NavigatorCamera"; + private static final long FRAME_MS = 16L; + /** Пауза следования после последнего жеста пользователя на карте. */ + public static final long USER_OVERRIDE_RESUME_MS = 5000L; + private static final float POSITION_ALPHA = 0.20f; + private static final float ZOOM_ALPHA = 0.14f; + /** Меньше alpha — плавнее поворот карты с небольшой задержкой. */ + private static final float BEARING_ALPHA = 0.09f; + + private final SettingsManager settingsManager; + private final Handler handler; + + private MapInterface map; + private Vessel lastVessel; + private boolean followLoopRunning; + private boolean userOverrideActive; + private long lastUserInteractionUptimeMs; + + private double targetLat = Double.NaN; + private double targetLon = Double.NaN; + private float targetZoom = 14f; + + private final Runnable followLoopRunnable = this::onFollowFrame; + private final Runnable overrideResumeRunnable = this::onOverrideResumeTimeout; + private final MapInterface.MapUserInteractionListener userInteractionListener = + this::onUserMapInteraction; + + public NavigatorCameraController(SettingsManager settingsManager) { + this.settingsManager = settingsManager; + this.handler = new Handler(Looper.getMainLooper()); + } + + public void setMapInterface(MapInterface map) { + if (this.map != null) { + this.map.setMapUserInteractionListener(null); + } + this.map = map; + if (map != null) { + map.setMapUserInteractionListener(userInteractionListener); + } + if (isEnabled()) { + ensureFollowLoop(); + } + } + + public boolean isUserOverrideActive() { + return userOverrideActive; + } + + /** + * Пользователь сдвинул/масштабировал/повернул карту — временно отключаем авто-камеру. + */ + public void onUserMapInteraction() { + if (!isEnabled()) { + return; + } + lastUserInteractionUptimeMs = SystemClock.uptimeMillis(); + if (!userOverrideActive) { + userOverrideActive = true; + Log.d(TAG, "Follow paused: user map interaction"); + } + scheduleOverrideResumeCheck(); + } + + public boolean isEnabled() { + return settingsManager != null && settingsManager.isNavigatorCameraEnabled(); + } + + public void setEnabled(boolean enabled) { + if (settingsManager == null) return; + settingsManager.setNavigatorCameraEnabled(enabled); + if (enabled) { + ensureFollowLoop(); + if (lastVessel != null) { + applyTargetsFromVessel(lastVessel); + } + } else { + clearUserOverride(); + stopFollowLoop(); + } + } + + /** + * Вызывается при каждом обновлении координат/скорости собственного судна. + */ + public void onOwnVesselUpdated(Vessel vessel) { + if (!isEnabled() || vessel == null) { + return; + } + lastVessel = vessel; + applyTargetsFromVessel(vessel); + if (map == null) { + return; + } + ensureFollowLoop(); + } + + /** + * Однократное центрирование с зумом по скорости (кнопка «на судно»). + */ + public void centerOnOwnVesselNow(Vessel vessel) { + if (vessel == null || map == null) return; + lastVessel = vessel; + double lat = vessel.getLatitude(); + double lon = vessel.getLongitude(); + if (!GeoUtils.isValidCoordinates(lat, lon)) return; + + float zoom = zoomForVessel(vessel); + long duration = settingsManager.getNavigatorCameraTransitionMs(); + float bearing = resolveTargetBearing(vessel); + if (Float.isNaN(bearing)) { + bearing = map.getBearing(); + } + map.moveCameraSmooth(lat, lon, zoom, duration); + targetLat = lat; + targetLon = lon; + targetZoom = zoom; + } + + private void applyTargetsFromVessel(Vessel vessel) { + targetLat = vessel.getLatitude(); + targetLon = vessel.getLongitude(); + targetZoom = zoomForVessel(vessel); + } + + private float zoomForVessel(Vessel vessel) { + double speed = vessel != null ? vessel.getSpeed() : 0.0; + return NavigatorZoomMath.zoomForSpeed( + speed, + settingsManager.getNavigatorZoomAtZeroSpeed(), + settingsManager.getNavigatorZoomAtMaxSpeed(), + settingsManager.getNavigatorMaxSpeedKnots()); + } + + private void ensureFollowLoop() { + if (!isEnabled() || followLoopRunning) return; + followLoopRunning = true; + handler.post(followLoopRunnable); + } + + private void stopFollowLoop() { + followLoopRunning = false; + handler.removeCallbacks(followLoopRunnable); + handler.removeCallbacks(overrideResumeRunnable); + } + + private void clearUserOverride() { + userOverrideActive = false; + handler.removeCallbacks(overrideResumeRunnable); + } + + private void scheduleOverrideResumeCheck() { + handler.removeCallbacks(overrideResumeRunnable); + handler.postDelayed(overrideResumeRunnable, USER_OVERRIDE_RESUME_MS); + } + + private void onOverrideResumeTimeout() { + if (!isEnabled() || !userOverrideActive) { + return; + } + long idle = SystemClock.uptimeMillis() - lastUserInteractionUptimeMs; + if (idle < USER_OVERRIDE_RESUME_MS) { + scheduleOverrideResumeCheck(); + return; + } + userOverrideActive = false; + Log.d(TAG, "Follow resumed after user idle"); + if (lastVessel != null) { + applyTargetsFromVessel(lastVessel); + } + } + + private void onFollowFrame() { + if (!isEnabled() || map == null) { + followLoopRunning = false; + return; + } + if (userOverrideActive) { + handler.postDelayed(followLoopRunnable, FRAME_MS); + return; + } + if (!GeoUtils.isValidCoordinates(targetLat, targetLon)) { + handler.postDelayed(followLoopRunnable, FRAME_MS); + return; + } + + double curLat = map.getCenterLatitude(); + double curLon = map.getCenterLongitude(); + float curZoom = map.getZoom(); + float curBearing = map.getBearing(); + + if (!GeoUtils.isValidCoordinates(curLat, curLon)) { + curLat = targetLat; + curLon = targetLon; + curZoom = targetZoom; + } + + double newLat = NavigatorZoomMath.lerp(curLat, targetLat, POSITION_ALPHA); + double newLon = NavigatorZoomMath.lerp(curLon, targetLon, POSITION_ALPHA); + float newZoom = NavigatorZoomMath.lerp(curZoom, targetZoom, ZOOM_ALPHA); + + float bearingArg = Float.NaN; + float targetBearing = resolveTargetBearing(lastVessel); + if (!Float.isNaN(targetBearing)) { + bearingArg = NavigatorZoomMath.lerpBearing(curBearing, targetBearing, BEARING_ALPHA); + } + + try { + map.setCameraView(newLat, newLon, newZoom, bearingArg); + } catch (Exception e) { + Log.w(TAG, "onFollowFrame: " + e.getMessage()); + } + + handler.postDelayed(followLoopRunnable, FRAME_MS); + } + + /** + * Bearing для карты по настройке вращения; {@link Float#NaN} — не менять (ручной режим). + */ + float resolveTargetBearing(Vessel vessel) { + if (settingsManager == null || vessel == null) { + return Float.NaN; + } + String mode = settingsManager.getMapRotationMode(); + if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) { + double c = vessel.getMagneticCompass(); + if (!Double.isNaN(c)) { + return NavigatorZoomMath.normalizeBearing360((float) c); + } + } else if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)) { + double cog = vessel.getCourse(); + if (!Double.isNaN(cog)) { + return NavigatorZoomMath.normalizeBearing360((float) cog); + } + } + return Float.NaN; + } + + /** + * Плавный переход к цели за фиксированное время (разовые вызовы). + */ + public static void runSmoothTransition(MapInterface map, + double fromLat, double fromLon, float fromZoom, + double toLat, double toLon, float toZoom, + long durationMs, + Handler handler, + Runnable onComplete) { + if (map == null || handler == null) return; + if (durationMs <= 0) { + map.setCameraView(toLat, toLon, toZoom, Float.NaN); + if (onComplete != null) onComplete.run(); + return; + } + final long startMs = SystemClock.uptimeMillis(); + final float fromBearing = map.getBearing(); + Runnable frame = new Runnable() { + @Override + public void run() { + float t = (SystemClock.uptimeMillis() - startMs) / (float) durationMs; + if (t >= 1f) t = 1f; + float eased = NavigatorZoomMath.easeOutCubic(t); + double lat = NavigatorZoomMath.lerp(fromLat, toLat, eased); + double lon = NavigatorZoomMath.lerp(fromLon, toLon, eased); + float zoom = NavigatorZoomMath.lerp(fromZoom, toZoom, eased); + try { + map.setCameraView(lat, lon, zoom, Float.NaN); + } catch (Exception ignore) { } + if (t < 1f) { + handler.postDelayed(this, FRAME_MS); + } else if (onComplete != null) { + onComplete.run(); + } + } + }; + handler.post(frame); + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java index 9d32b64..a594919 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java @@ -2,7 +2,11 @@ package com.grigowashere.aismap.maps; import android.content.Context; import android.graphics.Color; +import android.os.Handler; +import android.os.Looper; +import android.view.MotionEvent; +import com.grigowashere.aismap.controllers.NavigatorCameraController; import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.view.CursorOverlay; @@ -33,6 +37,8 @@ public class MapForgeImpl implements MapInterface { private Marker ownVesselMarker; private CursorOverlay cursorOverlay; private Vessel ownVessel; + private final Handler uiHandler = new Handler(Looper.getMainLooper()); + private MapUserInteractionListener mapUserInteractionListener; public MapForgeImpl(Context context, MapView mapView) { this.context = context; @@ -165,6 +171,53 @@ public class MapForgeImpl implements MapInterface { public float getZoom() { return mapView.getModel().mapViewPosition.getZoomLevel(); } + + @Override + public double getCenterLatitude() { + if (mapView == null) return Double.NaN; + try { + LatLong center = mapView.getModel().mapViewPosition.getCenter(); + return center != null ? center.latitude : Double.NaN; + } catch (Exception e) { + return Double.NaN; + } + } + + @Override + public double getCenterLongitude() { + if (mapView == null) return Double.NaN; + try { + LatLong center = mapView.getModel().mapViewPosition.getCenter(); + return center != null ? center.longitude : Double.NaN; + } catch (Exception e) { + return Double.NaN; + } + } + + @Override + public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) { + if (mapView == null) return; + LatLong position = new LatLong(latitude, longitude); + mapView.getModel().mapViewPosition.setCenter(position); + mapView.getModel().mapViewPosition.setZoomLevel((byte) zoom); + // MapForge: bearing не поддерживается + } + + @Override + public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) { + if (mapView == null) return; + double fromLat = getCenterLatitude(); + double fromLon = getCenterLongitude(); + float fromZoom = getZoom(); + if (Double.isNaN(fromLat) || Double.isNaN(fromLon)) { + fromLat = latitude; + fromLon = longitude; + fromZoom = zoom; + } + NavigatorCameraController.runSmoothTransition( + this, fromLat, fromLon, fromZoom, + latitude, longitude, zoom, durationMs, uiHandler, null); + } @Override public void setBearing(float bearing) { @@ -246,19 +299,26 @@ public class MapForgeImpl implements MapInterface { } } + @Override + public void setMapUserInteractionListener(MapUserInteractionListener listener) { + this.mapUserInteractionListener = listener; + } + /** * Настраивает слушатель движения карты для обновления курсора */ private void setupMapMovementListener() { - if (mapView != null) { - // mapView.getModel().mapViewPosition.addObserver(new org.mapsforge.map.model.Observer() { -// @Override -// public void onChange() { -// // Обновляем координаты курсора при движении карты -// updateCursorFromMapCenter(); -// } -// }); - } + if (mapView == null) return; + mapView.setOnTouchListener((v, event) -> { + int action = event.getActionMasked(); + if (mapUserInteractionListener != null + && (action == MotionEvent.ACTION_DOWN + || action == MotionEvent.ACTION_MOVE + || action == MotionEvent.ACTION_POINTER_DOWN)) { + mapUserInteractionListener.onUserMapInteraction(); + } + return false; + }); } @Override diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java b/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java index 070da47..b8a76ac 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java @@ -70,6 +70,40 @@ public interface MapInterface { * Получение текущего зума */ float getZoom(); + + /** + * Широта центра карты (видимой области). Если неизвестна — {@link Double#NaN}. + */ + default double getCenterLatitude() { + return Double.NaN; + } + + /** + * Долгота центра карты. Если неизвестна — {@link Double#NaN}. + */ + default double getCenterLongitude() { + return Double.NaN; + } + + /** + * Атомарно задаёт центр, зум и (опционально) bearing одним обновлением камеры. + * {@code bearingDegrees == Float.NaN} — bearing не меняется. + */ + default void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) { + centerOnPosition(latitude, longitude); + setZoom(zoom); + if (!Float.isNaN(bearingDegrees)) { + setBearing(bearingDegrees); + } + } + + /** + * Плавно перемещает камеру к позиции с заданным зумом. + * {@code durationMs == 0} — мгновенно через {@link #setCameraView}. + */ + default void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) { + setCameraView(latitude, longitude, zoom, Float.NaN); + } /** * Установка курса (bearing) карты в градусах (0 = север вверх) @@ -135,7 +169,46 @@ public interface MapInterface { * Очистить информацию об AIS судне */ void clearAisVesselInfo(); - + + /** + * Рисует/обновляет до трёх колец вокруг собственного судна. + * Все массивы должны быть одной длины (обычно 3: опасность/предупреждение/фильтр). + * Если карта ещё не готова или координаты невалидны — реализация молча игнорирует вызов. + * + * @param lat широта центра в градусах (собственное судно) + * @param lon долгота центра в градусах + * @param radiiMeters массив радиусов в метрах + * @param strokeColors массив цветов обводки (ARGB) + * @param fillColors массив цветов заливки (ARGB; 0 = без заливки) + * @param visible массив флагов видимости (false = пропустить кольцо) + */ + default void setOwnShipRangeRings(double lat, double lon, + double[] radiiMeters, int[] strokeColors, int[] fillColors, + boolean[] visible) { + // Карты, не поддерживающие кольца, безопасно игнорируют вызов. + } + + /** + * Полностью убирает все кольца дальности с карты. + */ + default void clearOwnShipRangeRings() { + // no-op для неподдерживающих реализаций + } + + /** + * Слушатель жестов пользователя на карте (пан, зум, поворот). + */ + interface MapUserInteractionListener { + void onUserMapInteraction(); + } + + /** + * Подписка на жесты пользователя; {@code null} — отписаться. + */ + default void setMapUserInteractionListener(MapUserInteractionListener listener) { + // no-op для реализаций без поддержки + } + /** * Интерфейс для обработки кликов по меткам */ diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java index 977ce83..59218f0 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java @@ -62,6 +62,24 @@ public class MapLibreMapImpl implements MapInterface { private static final String LAYER_SEAMARKS = "seamarks_layer"; private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source"; private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer"; + /** Подсветка целей в зоне предупреждения (CircleLayer под основным слоем судов). */ + private static final String LAYER_VESSELS_WARNING_HALO = "vessels_warning_halo"; + // Range rings around own ship (3 zones: danger / warning / filter). + private static final String[] RANGE_RING_SOURCES = { + "range_ring_danger_source", + "range_ring_warning_source", + "range_ring_filter_source" + }; + private static final String[] RANGE_RING_FILL_LAYERS = { + "range_ring_danger_fill", + "range_ring_warning_fill", + "range_ring_filter_fill" + }; + private static final String[] RANGE_RING_LINE_LAYERS = { + "range_ring_danger_line", + "range_ring_warning_line", + "range_ring_filter_line" + }; private static final String IMAGE_VESSEL_OWN = "ownship"; private static final String IMAGE_VESSEL_A = "vessel_icon_a"; private static final String IMAGE_VESSEL_B = "vessel_icon_b"; @@ -233,11 +251,18 @@ public class MapLibreMapImpl implements MapInterface { private final Map aisPredictionFeatures = new HashMap<>(); private MarkerClickListener markerClickListener; + private MapUserInteractionListener mapUserInteractionListener; // Pending центрирование до готовности карты/стиля private Double pendingCenterLat = null; private Double pendingCenterLon = null; + // ----- Warning-zone подсветка целей ----- + /** Радиус зоны предупреждения в метрах; 0 = подсветка отключена. */ + private volatile double warningRadiusMeters = 0.0; + private volatile double warningOwnLat = Double.NaN; + private volatile double warningOwnLon = Double.NaN; + public MapLibreMapImpl(Context context, MapView mapView) { this.context = context; this.mapView = mapView; @@ -556,6 +581,7 @@ public class MapLibreMapImpl implements MapInterface { String iconName = pickIconNameFor(vessel); props.put("icon", iconName); props.put("stale", stale); + props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude())); // Проставим статусную иконку, если статус поддержан String status = vessel.getNavigationalStatus(); String statusIcon = mapStatusToIcon(status); @@ -613,6 +639,7 @@ public class MapLibreMapImpl implements MapInterface { JSONObject props = feature.getJSONObject("properties"); props.put("icon", pickIconNameFor(vessel)); props.put("stale", stale); + props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude())); String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus()); if (statusIcon != null) { props.put("status_icon", statusIcon); @@ -633,6 +660,51 @@ public class MapLibreMapImpl implements MapInterface { } } + /** + * Сохраняет координаты собственного судна и радиус зоны предупреждения для + * data-driven подсветки целей. Передайте {@code warningRadiusMeters <= 0} + * чтобы выключить подсветку. + */ + public void setWarningZoneParams(double ownLat, double ownLon, double warningRadiusMeters) { + this.warningOwnLat = ownLat; + this.warningOwnLon = ownLon; + this.warningRadiusMeters = warningRadiusMeters; + // Перепроставим warning_zone у уже известных судов и обновим источник. + try { + for (java.util.Map.Entry e : idToFeature.entrySet()) { + if ("own_vessel".equals(e.getKey())) continue; + AISVessel v = idToAisVessel.get(e.getKey()); + if (v == null) continue; + JSONObject feature = e.getValue(); + if (feature == null) continue; + try { + JSONObject props = feature.getJSONObject("properties"); + props.put("warning_zone", isInWarningZone(v.getLatitude(), v.getLongitude())); + } catch (Exception ignore) {} + } + uiHandler.post(this::refreshGeoJson); + } catch (Throwable ignore) {} + } + + /** + * Проверяет, попадает ли точка в зону предупреждения вокруг собственного + * судна. Если параметры зоны не заданы — возвращает {@code false}. + */ + private boolean isInWarningZone(double lat, double lon) { + double r = warningRadiusMeters; + if (!(r > 0.0)) return false; + double oLat = warningOwnLat; + double oLon = warningOwnLon; + if (Double.isNaN(oLat) || Double.isNaN(oLon)) return false; + if (!GeoUtils.isValidCoordinates(lat, lon)) return false; + try { + double d = GeoUtils.calculateDistance(oLat, oLon, lat, lon); + return d <= r; + } catch (Throwable t) { + return false; + } + } + @Override public void removeAISVesselMarker(String mmsi) { if (mmsi == null) return; @@ -858,6 +930,74 @@ public class MapLibreMapImpl implements MapInterface { } } + @Override + public double getCenterLatitude() { + if (maplibreMap == null) return Double.NaN; + try { + org.maplibre.android.geometry.LatLng t = maplibreMap.getCameraPosition().target; + return t != null ? t.getLatitude() : Double.NaN; + } catch (Exception e) { + return Double.NaN; + } + } + + @Override + public double getCenterLongitude() { + if (maplibreMap == null) return Double.NaN; + try { + org.maplibre.android.geometry.LatLng t = maplibreMap.getCameraPosition().target; + return t != null ? t.getLongitude() : Double.NaN; + } catch (Exception e) { + return Double.NaN; + } + } + + @Override + public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) { + if (maplibreMap == null || mapView == null) { + pendingCenterLat = latitude; + pendingCenterLon = longitude; + return; + } + try { + org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition(); + float bearing = Float.isNaN(bearingDegrees) ? (float) current.bearing : bearingDegrees; + maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder() + .target(new LatLng(latitude, longitude)) + .zoom(zoom) + .bearing(bearing) + .tilt(current.tilt) + .build()); + } catch (Exception e) { + Log.w(TAG, "setCameraView: " + e.getMessage()); + } + } + + @Override + public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) { + if (maplibreMap == null) return; + if (durationMs <= 0) { + setCameraView(latitude, longitude, zoom, Float.NaN); + return; + } + try { + org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition(); + org.maplibre.android.camera.CameraPosition target = + new org.maplibre.android.camera.CameraPosition.Builder() + .target(new org.maplibre.android.geometry.LatLng(latitude, longitude)) + .zoom(zoom) + .bearing(current.bearing) + .tilt(current.tilt) + .build(); + maplibreMap.animateCamera( + org.maplibre.android.camera.CameraUpdateFactory.newCameraPosition(target), + (int) durationMs); + } catch (Exception e) { + Log.w(TAG, "moveCameraSmooth: " + e.getMessage()); + setCameraView(latitude, longitude, zoom, Float.NaN); + } + } + @Override public void addLayer(String layerId, Object layerData) { if (style == null || !isStyleValid()) { @@ -949,6 +1089,39 @@ public class MapLibreMapImpl implements MapInterface { // Отладочные линии удалены + // Подсветка целей в зоне предупреждения (под основным слоем судов). + // Виден только для feature.properties.warning_zone == true. + if (style.getLayer(LAYER_VESSELS_WARNING_HALO) == null) { + try { + int haloColor = androidx.core.content.ContextCompat.getColor( + context, com.grigowashere.aismap.R.color.range_target_warning_halo); + org.maplibre.android.style.layers.CircleLayer haloLayer = + new org.maplibre.android.style.layers.CircleLayer(LAYER_VESSELS_WARNING_HALO, SOURCE_VESSELS) + .withFilter(Expression.eq(Expression.get("warning_zone"), true)) + .withProperties( + PropertyFactory.circleColor(haloColor), + PropertyFactory.circleOpacity(0.55f), + PropertyFactory.circleStrokeColor(haloColor), + PropertyFactory.circleStrokeOpacity(0.95f), + PropertyFactory.circleStrokeWidth(2.0f), + PropertyFactory.circleRadius( + Expression.interpolate( + Expression.linear(), + Expression.zoom(), + Expression.stop(5, 6.0f), + Expression.stop(8, 9.0f), + Expression.stop(12, 14.0f), + Expression.stop(15, 20.0f), + Expression.stop(17, 26.0f) + ) + ) + ); + style.addLayer(haloLayer); + } catch (Throwable t) { + Log.w(TAG, "Failed to add warning halo layer: " + t.getMessage()); + } + } + // Слой символов (основные иконки) if (style.getLayer(LAYER_VESSELS) == null) { SymbolLayer layer = new SymbolLayer(LAYER_VESSELS, SOURCE_VESSELS) @@ -3161,11 +3334,19 @@ public class MapLibreMapImpl implements MapInterface { Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e); } } + @Override + public void setMapUserInteractionListener(MapUserInteractionListener listener) { + this.mapUserInteractionListener = listener; + } + private void setupMapMovementListener() { if (maplibreMap != null) { - maplibreMap.addOnCameraMoveListener(() -> { - // Обновляем координаты курсора при движении карты - updateCursorFromMapCenter(); + maplibreMap.addOnCameraMoveListener(() -> updateCursorFromMapCenter()); + maplibreMap.addOnCameraMoveStartedListener(reason -> { + if (reason == org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE + && mapUserInteractionListener != null) { + mapUserInteractionListener.onUserMapInteraction(); + } }); } } @@ -3198,4 +3379,145 @@ public class MapLibreMapImpl implements MapInterface { Log.e(TAG, "removeSeamarksLayer: ошибка удаления слоя морских знаков", e); } } + + // ===== Range rings around own ship ===== + + /** + * Количество вершин в полигоне-аппроксимации круга. 64 — компромисс + * между плавностью контура на низком зуме и стоимостью обновления + * GeoJsonSource на 1 Hz. + */ + private static final int RANGE_RING_VERTICES = 64; + /** Радиус Земли (м) — тот же, что и в GeoUtils, держим локально, чтобы избежать межмодульной зависимости. */ + private static final double RANGE_EARTH_RADIUS_M = 6371000.0; + + @Override + public void setOwnShipRangeRings(double lat, double lon, + double[] radiiMeters, int[] strokeColors, int[] fillColors, + boolean[] visible) { + if (style == null || !isStyleValid()) { + return; + } + if (radiiMeters == null || strokeColors == null || fillColors == null || visible == null) { + return; + } + // Координаты должны быть в валидном диапазоне; иначе круги превратятся в мусор. + if (Double.isNaN(lat) || Double.isNaN(lon) || + lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { + return; + } + int n = Math.min(RANGE_RING_SOURCES.length, + Math.min(radiiMeters.length, Math.min(strokeColors.length, + Math.min(fillColors.length, visible.length)))); + + try { + for (int i = 0; i < n; i++) { + String sourceId = RANGE_RING_SOURCES[i]; + String fillId = RANGE_RING_FILL_LAYERS[i]; + String lineId = RANGE_RING_LINE_LAYERS[i]; + + if (!visible[i] || radiiMeters[i] <= 0.0) { + removeRangeRingLayers(sourceId, fillId, lineId); + continue; + } + + org.maplibre.geojson.Polygon polygon = buildCirclePolygon(lat, lon, radiiMeters[i]); + org.maplibre.geojson.Feature feature = org.maplibre.geojson.Feature.fromGeometry(polygon); + + GeoJsonSource source = (GeoJsonSource) style.getSource(sourceId); + if (source == null) { + source = new GeoJsonSource(sourceId, feature); + style.addSource(source); + } else { + source.setGeoJson(feature); + } + + if (style.getLayer(fillId) == null) { + org.maplibre.android.style.layers.FillLayer fillLayer = + new org.maplibre.android.style.layers.FillLayer(fillId, sourceId); + fillLayer.setProperties( + org.maplibre.android.style.layers.PropertyFactory.fillColor(fillColors[i]), + org.maplibre.android.style.layers.PropertyFactory.fillOpacity(1.0f) + ); + if (style.getLayer(LAYER_VESSELS) != null) { + style.addLayerBelow(fillLayer, LAYER_VESSELS); + } else { + style.addLayer(fillLayer); + } + } else { + style.getLayer(fillId).setProperties( + org.maplibre.android.style.layers.PropertyFactory.fillColor(fillColors[i]) + ); + } + + if (style.getLayer(lineId) == null) { + org.maplibre.android.style.layers.LineLayer lineLayer = + new org.maplibre.android.style.layers.LineLayer(lineId, sourceId); + lineLayer.setProperties( + org.maplibre.android.style.layers.PropertyFactory.lineColor(strokeColors[i]), + org.maplibre.android.style.layers.PropertyFactory.lineWidth(2f), + org.maplibre.android.style.layers.PropertyFactory.lineOpacity(0.95f) + ); + if (style.getLayer(LAYER_VESSELS) != null) { + style.addLayerBelow(lineLayer, LAYER_VESSELS); + } else { + style.addLayer(lineLayer); + } + } else { + style.getLayer(lineId).setProperties( + org.maplibre.android.style.layers.PropertyFactory.lineColor(strokeColors[i]) + ); + } + } + } catch (Exception e) { + Log.w(TAG, "setOwnShipRangeRings: " + e.getMessage(), e); + } + } + + @Override + public void clearOwnShipRangeRings() { + if (style == null || !isStyleValid()) { + return; + } + try { + for (int i = 0; i < RANGE_RING_SOURCES.length; i++) { + removeRangeRingLayers(RANGE_RING_SOURCES[i], RANGE_RING_FILL_LAYERS[i], RANGE_RING_LINE_LAYERS[i]); + } + } catch (Exception e) { + Log.w(TAG, "clearOwnShipRangeRings: " + e.getMessage(), e); + } + } + + private void removeRangeRingLayers(String sourceId, String fillId, String lineId) { + try { + if (style.getLayer(fillId) != null) style.removeLayer(fillId); + if (style.getLayer(lineId) != null) style.removeLayer(lineId); + if (style.getSource(sourceId) != null) style.removeSource(sourceId); + } catch (Exception ignore) {} + } + + /** + * Возвращает полигон-аппроксимацию окружности радиуса {@code radiusMeters} + * вокруг точки ({@code centerLat}, {@code centerLon}) с {@link #RANGE_RING_VERTICES} вершинами. + */ + private static org.maplibre.geojson.Polygon buildCirclePolygon(double centerLat, double centerLon, double radiusMeters) { + double latRad = Math.toRadians(centerLat); + double lonRad = Math.toRadians(centerLon); + double angularDistance = radiusMeters / RANGE_EARTH_RADIUS_M; + + java.util.List ring = new java.util.ArrayList<>(RANGE_RING_VERTICES + 1); + for (int i = 0; i <= RANGE_RING_VERTICES; i++) { + double bearing = Math.toRadians((360.0 / RANGE_RING_VERTICES) * i); + double lat2 = Math.asin(Math.sin(latRad) * Math.cos(angularDistance) + + Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearing)); + double lon2 = lonRad + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latRad), + Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(lat2)); + ring.add(org.maplibre.geojson.Point.fromLngLat(Math.toDegrees(lon2), Math.toDegrees(lat2))); + } + + java.util.List> outer = new java.util.ArrayList<>(1); + outer.add(ring); + return org.maplibre.geojson.Polygon.fromLngLats(outer); + } } diff --git a/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java b/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java new file mode 100644 index 0000000..02a4c6f --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java @@ -0,0 +1,103 @@ +package com.grigowashere.aismap.maps; + +import android.util.Log; + +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.camera.CameraUpdateFactory; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.maps.MapLibreMap; +import org.maplibre.android.maps.MapView; + +/** + * Минимальная инициализация MapLibre для режима картплоттера: + * только береговые тайлы, без маркеров AIS и без жестов. + */ +public class RadarMapHelper { + + private static final String TAG = "RadarMapHelper"; + private static final String STYLE_URL = + "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + + private final MapView mapView; + private MapLibreMap map; + private boolean styleLoaded; + + public RadarMapHelper(MapView mapView) { + this.mapView = mapView; + } + + public void initialize(Runnable onReady) { + mapView.getMapAsync(loadedMap -> { + map = loadedMap; + try { + if (map.getUiSettings() != null) { + map.getUiSettings().setCompassEnabled(false); + map.getUiSettings().setAttributionEnabled(false); + map.getUiSettings().setLogoEnabled(false); + map.getUiSettings().setRotateGesturesEnabled(false); + map.getUiSettings().setScrollGesturesEnabled(false); + map.getUiSettings().setZoomGesturesEnabled(false); + map.getUiSettings().setTiltGesturesEnabled(false); + } + } catch (Exception e) { + Log.w(TAG, "UI settings: " + e.getMessage()); + } + map.setStyle(STYLE_URL, style -> { + styleLoaded = true; + if (onReady != null) onReady.run(); + }); + }); + } + + public void onStart() { + mapView.onStart(); + } + + public void onResume() { + mapView.onResume(); + } + + public void onPause() { + mapView.onPause(); + } + + public void onStop() { + mapView.onStop(); + } + + public void onDestroy() { + mapView.onDestroy(); + } + + public void onSaveInstanceState(android.os.Bundle outState) { + mapView.onSaveInstanceState(outState); + } + + public void onLowMemory() { + mapView.onLowMemory(); + } + + /** + * Центрирует карту на собственном судне; bearing задаёт режим «курс вверх». + * + * @param rangeMeters радиус PPI для подбора зума + */ + public void centerOnOwnShip(double lat, double lon, float bearingDeg, double rangeMeters) { + if (map == null || !styleLoaded) return; + if (Double.isNaN(lat) || Double.isNaN(lon)) return; + double zoom = zoomForRangeMeters(rangeMeters); + CameraPosition position = new CameraPosition.Builder() + .target(new LatLng(lat, lon)) + .zoom(zoom) + .bearing(bearingDeg) + .tilt(0.0) + .build(); + map.easeCamera(CameraUpdateFactory.newCameraPosition(position), 400); + } + + /** Подбирает зум так, чтобы весь радиус PPI помещался в круговой области. */ + static double zoomForRangeMeters(double rangeMeters) { + double nm = Math.max(0.25, rangeMeters / 1852.0); + return 14.8 - Math.log10(nm) * 2.35; + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java index a208c82..4ff3045 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java @@ -40,6 +40,7 @@ public class YandexMapImpl implements MapInterface { // Слушатель поворота карты private com.yandex.mapkit.map.InputListener inputListener; + private MapUserInteractionListener mapUserInteractionListener; private float lastMapAzimuth = 0.0f; // Курсор overlay @@ -134,6 +135,7 @@ public class YandexMapImpl implements MapInterface { if (markerManager != null) { markerManager.updateAISVesselMarker(vessel); } + updateWarningHaloForVessel(vessel); } @Override @@ -141,6 +143,7 @@ public class YandexMapImpl implements MapInterface { if (markerManager != null) { markerManager.updateAISVesselMarker(vessel); } + updateWarningHaloForVessel(vessel); } @Override @@ -148,6 +151,7 @@ public class YandexMapImpl implements MapInterface { if (vessels == null || markerManager == null) return; for (AISVessel vessel : vessels) { markerManager.updateAISVesselMarker(vessel); + updateWarningHaloForVessel(vessel); } } @@ -156,6 +160,12 @@ public class YandexMapImpl implements MapInterface { if (markerManager != null) { markerManager.removeAISVesselMarker(mmsi); } + if (mmsi != null) { + com.yandex.mapkit.map.CircleMapObject halo = warningHalos.remove(mmsi); + if (halo != null && mapObjects != null) { + try { mapObjects.remove(halo); } catch (Throwable ignore) {} + } + } } @Override @@ -163,6 +173,7 @@ public class YandexMapImpl implements MapInterface { if (markerManager != null) { markerManager.clearAISVesselMarkers(); } + clearAllWarningHalos(); } @Override @@ -185,6 +196,56 @@ public class YandexMapImpl implements MapInterface { return mapView.getMap().getCameraPosition().getZoom(); } + @Override + public double getCenterLatitude() { + try { + com.yandex.mapkit.geometry.Point p = mapView.getMap().getCameraPosition().getTarget(); + return p != null ? p.getLatitude() : Double.NaN; + } catch (Exception e) { + return Double.NaN; + } + } + + @Override + public double getCenterLongitude() { + try { + com.yandex.mapkit.geometry.Point p = mapView.getMap().getCameraPosition().getTarget(); + return p != null ? p.getLongitude() : Double.NaN; + } catch (Exception e) { + return Double.NaN; + } + } + + @Override + public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) { + try { + Point point = new Point(latitude, longitude); + CameraPosition current = mapView.getMap().getCameraPosition(); + float azimuth = Float.isNaN(bearingDegrees) ? current.getAzimuth() : bearingDegrees; + CameraPosition pos = new CameraPosition(point, zoom, azimuth, current.getTilt()); + mapView.getMap().move(pos, new Animation(Animation.Type.SMOOTH, 0.35f), null); + } catch (Exception ignore) { + centerOnPosition(latitude, longitude); + setZoom(zoom); + if (!Float.isNaN(bearingDegrees)) { + setBearing(bearingDegrees); + } + } + } + + @Override + public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) { + try { + Point point = new Point(latitude, longitude); + float durationSec = durationMs <= 0 ? 0.1f : Math.min(3f, durationMs / 1000f); + CameraPosition current = mapView.getMap().getCameraPosition(); + CameraPosition pos = new CameraPosition(point, zoom, current.getAzimuth(), current.getTilt()); + mapView.getMap().move(pos, new Animation(Animation.Type.SMOOTH, durationSec), null); + } catch (Exception ignore) { + setCameraView(latitude, longitude, zoom, Float.NaN); + } + } + @Override public void setBearing(float bearing) { try { @@ -325,6 +386,173 @@ public class YandexMapImpl implements MapInterface { // В YandexMapImpl VesselPathController не используется напрямую, // но если в будущем будет использоваться, нужно добавить очистку } + + // ===== Range rings around own ship ===== + + /** Ссылки на нарисованные кольца (3 зоны). */ + private final com.yandex.mapkit.map.CircleMapObject[] rangeRingObjects = + new com.yandex.mapkit.map.CircleMapObject[3]; + + @Override + public void setOwnShipRangeRings(double lat, double lon, + double[] radiiMeters, int[] strokeColors, int[] fillColors, + boolean[] visible) { + if (mapObjects == null) return; + if (radiiMeters == null || strokeColors == null || fillColors == null || visible == null) return; + if (Double.isNaN(lat) || Double.isNaN(lon)) return; + + int n = Math.min(rangeRingObjects.length, + Math.min(radiiMeters.length, Math.min(strokeColors.length, + Math.min(fillColors.length, visible.length)))); + try { + for (int i = 0; i < n; i++) { + if (!visible[i] || radiiMeters[i] <= 0.0) { + if (rangeRingObjects[i] != null) { + try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {} + rangeRingObjects[i] = null; + } + continue; + } + com.yandex.mapkit.geometry.Circle circle = new com.yandex.mapkit.geometry.Circle( + new com.yandex.mapkit.geometry.Point(lat, lon), + (float) radiiMeters[i]); + if (rangeRingObjects[i] == null) { + rangeRingObjects[i] = mapObjects.addCircle(circle); + try { + rangeRingObjects[i].setStrokeColor(strokeColors[i]); + rangeRingObjects[i].setStrokeWidth(2f); + rangeRingObjects[i].setFillColor(fillColors[i]); + } catch (Throwable ignore) {} + } else { + try { + rangeRingObjects[i].setGeometry(circle); + rangeRingObjects[i].setStrokeColor(strokeColors[i]); + rangeRingObjects[i].setStrokeWidth(2f); + rangeRingObjects[i].setFillColor(fillColors[i]); + } catch (Throwable t) { + // Если объект финализирован — пересоздаём. + try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {} + rangeRingObjects[i] = mapObjects.addCircle(circle); + try { + rangeRingObjects[i].setStrokeColor(strokeColors[i]); + rangeRingObjects[i].setStrokeWidth(2f); + rangeRingObjects[i].setFillColor(fillColors[i]); + } catch (Throwable ignore) {} + } + } + } + } catch (Throwable t) { + android.util.Log.w("YandexMapImpl", "setOwnShipRangeRings: " + t.getMessage()); + } + } + + @Override + public void clearOwnShipRangeRings() { + if (mapObjects == null) return; + for (int i = 0; i < rangeRingObjects.length; i++) { + if (rangeRingObjects[i] != null) { + try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {} + rangeRingObjects[i] = null; + } + } + } + + // ===== Warning-zone подсветка целей ===== + + private final Map warningHalos = new HashMap<>(); + private volatile double warningRadiusMeters = 0.0; + private volatile double warningOwnLat = Double.NaN; + private volatile double warningOwnLon = Double.NaN; + /** + * Радиус halo-кольца вокруг цели (в метрах). Подобран небольшим, чтобы + * не загромождать карту, и виден на средних/больших зумах. + */ + private static final double WARNING_HALO_RADIUS_M = 250.0; + + /** + * Сохраняет параметры зоны предупреждения для подсветки целей. + * При {@code warningRadiusMeters <= 0} подсветка очищается. + */ + public void setWarningZoneParams(double ownLat, double ownLon, double warningRadiusMeters) { + this.warningOwnLat = ownLat; + this.warningOwnLon = ownLon; + this.warningRadiusMeters = warningRadiusMeters; + if (!(warningRadiusMeters > 0.0)) { + clearAllWarningHalos(); + } + } + + private boolean isInWarningZone(double lat, double lon) { + double r = warningRadiusMeters; + if (!(r > 0.0)) return false; + double oLat = warningOwnLat; + double oLon = warningOwnLon; + if (Double.isNaN(oLat) || Double.isNaN(oLon)) return false; + try { + double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(oLat, oLon, lat, lon); + return d <= r; + } catch (Throwable t) { + return false; + } + } + + /** Создаёт/обновляет/удаляет halo для одной цели в зависимости от попадания в зону. */ + private void updateWarningHaloForVessel(AISVessel vessel) { + if (vessel == null || vessel.getMmsi() == null || mapObjects == null) return; + String mmsi = vessel.getMmsi(); + boolean inZone = isInWarningZone(vessel.getLatitude(), vessel.getLongitude()); + com.yandex.mapkit.map.CircleMapObject existing = warningHalos.get(mmsi); + try { + if (!inZone) { + if (existing != null) { + try { mapObjects.remove(existing); } catch (Throwable ignore) {} + warningHalos.remove(mmsi); + } + return; + } + int strokeColor = androidx.core.content.ContextCompat.getColor(context, R.color.range_target_warning_halo); + int fillColor = (strokeColor & 0x00FFFFFF) | 0x55000000; + com.yandex.mapkit.geometry.Circle circle = new com.yandex.mapkit.geometry.Circle( + new com.yandex.mapkit.geometry.Point(vessel.getLatitude(), vessel.getLongitude()), + (float) WARNING_HALO_RADIUS_M); + if (existing == null) { + com.yandex.mapkit.map.CircleMapObject created = mapObjects.addCircle(circle); + try { + created.setStrokeColor(strokeColor); + created.setStrokeWidth(2f); + created.setFillColor(fillColor); + } catch (Throwable ignore) {} + warningHalos.put(mmsi, created); + } else { + try { + existing.setGeometry(circle); + } catch (Throwable t) { + try { mapObjects.remove(existing); } catch (Throwable ignore) {} + com.yandex.mapkit.map.CircleMapObject created = mapObjects.addCircle(circle); + try { + created.setStrokeColor(strokeColor); + created.setStrokeWidth(2f); + created.setFillColor(fillColor); + } catch (Throwable ignore2) {} + warningHalos.put(mmsi, created); + } + } + } catch (Throwable t) { + android.util.Log.w("YandexMapImpl", "updateWarningHalo: " + t.getMessage()); + } + } + + private void clearAllWarningHalos() { + if (mapObjects == null) { + warningHalos.clear(); + return; + } + for (com.yandex.mapkit.map.CircleMapObject obj : warningHalos.values()) { + if (obj == null) continue; + try { mapObjects.remove(obj); } catch (Throwable ignore) {} + } + warningHalos.clear(); + } /** * Обновление всех путей судов на карте (заглушка для Yandex) @@ -487,13 +715,21 @@ public class YandexMapImpl implements MapInterface { /** * Настраивает слушатель движения карты для обновления курсора */ + @Override + public void setMapUserInteractionListener(MapUserInteractionListener listener) { + this.mapUserInteractionListener = listener; + } + private void setupMapMovementListener() { if (mapView != null) { mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() { @Override public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, com.yandex.mapkit.map.CameraPosition cameraPosition, com.yandex.mapkit.map.CameraUpdateReason cameraUpdateReason, boolean finished) { - // Обновляем координаты курсора при движении карты updateCursorFromMapCenter(); + if (cameraUpdateReason == com.yandex.mapkit.map.CameraUpdateReason.GESTURES + && mapUserInteractionListener != null) { + mapUserInteractionListener.onUserMapInteraction(); + } } }); } diff --git a/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java b/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java index de512b6..553975a 100644 --- a/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java +++ b/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java @@ -31,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.switchmaterial.SwitchMaterial; import com.grigowashere.aismap.R; import com.grigowashere.aismap.utils.SettingsManager; +import com.grigowashere.aismap.utils.UiInsetsUtils; import java.util.ArrayList; import java.util.List; @@ -55,6 +56,9 @@ public class InterfacesSettingsActivity extends AppCompatActivity { private EditText etBleBridgeHost; private EditText etBleBridgePort; + // BLE optional battery read (system pairing trigger on some devices) + private SwitchMaterial swBleBatteryEnabled; + private Button btnSave; private Button btnCancel; @@ -72,6 +76,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_interfaces_settings); + applySettingsInsets(); settingsManager = new SettingsManager(this); BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); btAdapter = bm != null ? bm.getAdapter() : null; @@ -82,6 +87,14 @@ public class InterfacesSettingsActivity extends AppCompatActivity { setupRecycler(); } + private void applySettingsInsets() { + View scroll = findViewById(R.id.settings_scroll); + if (scroll != null) { + int pad = Math.round(getResources().getDisplayMetrics().density * 16); + UiInsetsUtils.applySystemBarPadding(scroll, pad); + } + } + private void initViews() { etUdpPort = findViewById(R.id.et_udp_port); swUdpEnabled = findViewById(R.id.switch_udp_enabled); @@ -90,6 +103,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity { swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled); etBleBridgeHost = findViewById(R.id.et_ble_udp_host); etBleBridgePort = findViewById(R.id.et_ble_udp_port); + swBleBatteryEnabled = findViewById(R.id.switch_ble_battery_enabled); btnSave = findViewById(R.id.btn_save); btnCancel = findViewById(R.id.btn_cancel); btnBleScan = findViewById(R.id.btn_ble_scan); @@ -107,6 +121,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity { swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled()); etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost()); etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort())); + + if (swBleBatteryEnabled != null) { + swBleBatteryEnabled.setChecked(settingsManager.isBleReadBatteryEnabled()); + } } private void setupHandlers() { @@ -202,6 +220,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity { int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535); settingsManager.setBleUdpBridgePort(brPort); + if (swBleBatteryEnabled != null) { + settingsManager.setBleReadBatteryEnabled(swBleBatteryEnabled.isChecked()); + } + Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show(); finish(); } catch (Exception e) { diff --git a/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java b/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java index d09c443..5342a77 100644 --- a/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java +++ b/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java @@ -196,60 +196,63 @@ public class BottomSheetsManager { if (tvTitle != null) { String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() ? vessel.getVesselName() : "AIS СУДНО"; + // Флаг страны по MMSI оставляем — это единственный визуальный + // маркер, который тут реально несёт смысл. Остальные эмодзи в + // карточке цели убраны, чтобы текст не выглядел как чат. String flag = getFlagEmojiForMMSI(vessel.getMmsi()); - tvTitle.setText((flag != null ? flag + " " : "") + "🚢 " + name); + tvTitle.setText((flag != null ? flag + " " : "") + name); } - if (tvMmsi != null) tvMmsi.setText("🆔 MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--")); - if (tvCallsign != null) tvCallsign.setText("📻 Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--")); - if (tvImo != null) tvImo.setText("🏷️ IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--")); - if (tvType != null) tvType.setText("🚢 Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--")); + if (tvMmsi != null) tvMmsi.setText("MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--")); + if (tvCallsign != null) tvCallsign.setText("Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--")); + if (tvImo != null) tvImo.setText("IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--")); + if (tvType != null) tvType.setText("Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--")); if (tvPosition != null) { if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { - tvPosition.setText(String.format("📍 Координаты: %.6f, %.6f", vessel.getLatitude(), vessel.getLongitude())); + tvPosition.setText(String.format("Координаты: %.6f, %.6f", vessel.getLatitude(), vessel.getLongitude())); } else { - tvPosition.setText("📍 Координаты: --"); + tvPosition.setText("Координаты: --"); } } - if (tvCourse != null) tvCourse.setText(vessel.getCourse() > 0 ? String.format("🧭 COG: %.1f°", vessel.getCourse()) : "🧭 COG: --°"); - if (tvRot != null) tvRot.setText(vessel.getRateOfTurn() != 0 ? String.format("🔄 ROT: %.1f°/мин", vessel.getRateOfTurn()) : "🔄 ROT: --°/мин"); - if (tvHeading != null) tvHeading.setText(vessel.getHeading() > 0 ? String.format("🧭 HDG: %.1f°", vessel.getHeading()) : "🧭 HDG: --°"); - if (tvSpeed != null) tvSpeed.setText(vessel.getSpeed() > 0 ? String.format("⚡ Скорость: %.1f узлов", vessel.getSpeed()) : "⚡ Скорость: -- узлов"); - if (tvDimensions != null) tvDimensions.setText((vessel.getLength() > 0 && vessel.getWidth() > 0) ? String.format("📏 Размеры: %.1f x %.1f м", vessel.getLength(), vessel.getWidth()) : "📏 Размеры: --"); - if (tvDraft != null) tvDraft.setText(vessel.getDraft() > 0 ? String.format("🌊 Осадка: %.1f м", vessel.getDraft()) : "🌊 Осадка: -- м"); - if (tvDestination != null) tvDestination.setText("🎯 Назначение: " + (vessel.getDestination() != null ? vessel.getDestination() : "--")); - if (tvEta != null) tvEta.setText(vessel.getEta() != null ? String.format("⏰ ETA: %s", vessel.getEta().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))) : "⏰ ETA: --"); - if (tvNavStatus != null) tvNavStatus.setText("🚦 Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--")); - if (tvClass != null) tvClass.setText("📋 Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--")); + if (tvCourse != null) tvCourse.setText(vessel.getCourse() > 0 ? String.format("COG: %.1f°", vessel.getCourse()) : "COG: --°"); + if (tvRot != null) tvRot.setText(vessel.getRateOfTurn() != 0 ? String.format("ROT: %.1f°/мин", vessel.getRateOfTurn()) : "ROT: --°/мин"); + if (tvHeading != null) tvHeading.setText(vessel.getHeading() > 0 ? String.format("HDG: %.1f°", vessel.getHeading()) : "HDG: --°"); + if (tvSpeed != null) tvSpeed.setText(vessel.getSpeed() > 0 ? String.format("Скорость: %.1f узлов", vessel.getSpeed()) : "Скорость: -- узлов"); + if (tvDimensions != null) tvDimensions.setText((vessel.getLength() > 0 && vessel.getWidth() > 0) ? String.format("Размеры: %.1f x %.1f м", vessel.getLength(), vessel.getWidth()) : "Размеры: --"); + if (tvDraft != null) tvDraft.setText(vessel.getDraft() > 0 ? String.format("Осадка: %.1f м", vessel.getDraft()) : "Осадка: -- м"); + if (tvDestination != null) tvDestination.setText("Назначение: " + (vessel.getDestination() != null ? vessel.getDestination() : "--")); + if (tvEta != null) tvEta.setText(vessel.getEta() != null ? String.format("ETA: %s", vessel.getEta().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))) : "ETA: --"); + if (tvNavStatus != null) tvNavStatus.setText("Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--")); + if (tvClass != null) tvClass.setText("Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--")); if (tvSignal != null) { if (vessel.getSignalStrength() > 0) { - tvSignal.setText(String.format("📶 Сигнал: %d", vessel.getSignalStrength())); + tvSignal.setText(String.format("Сигнал: %d", vessel.getSignalStrength())); } else { - tvSignal.setText(vessel.isPositionAccuracy() ? "📶 Точность: высокая" : "📶 Точность: низкая"); + tvSignal.setText(vessel.isPositionAccuracy() ? "Точность: высокая" : "Точность: низкая"); } } - if (tvLastUpdate != null) tvLastUpdate.setText(vessel.getLastUpdate() != null ? String.format("🕐 Обновлено: %s", vessel.getLastUpdate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))) : "🕐 Обновлено: --"); + if (tvLastUpdate != null) tvLastUpdate.setText(vessel.getLastUpdate() != null ? String.format("Обновлено: %s", vessel.getLastUpdate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))) : "Обновлено: --"); if (tvDistance != null || tvBearing != null) { Vessel ourVessel = appCoordinator.getOwnVessel(); if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0 && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance(ourVessel.getLatitude(), ourVessel.getLongitude(), vessel.getLatitude(), vessel.getLongitude()); - if (tvDistance != null) tvDistance.setText("📏 Расстояние: " + com.grigowashere.aismap.utils.NavigationUtils.formatDistance(distance)); + if (tvDistance != null) tvDistance.setText("Расстояние: " + com.grigowashere.aismap.utils.NavigationUtils.formatDistance(distance)); double bearing = com.grigowashere.aismap.utils.NavigationUtils.calculateBearing(ourVessel.getLatitude(), ourVessel.getLongitude(), vessel.getLatitude(), vessel.getLongitude()); double relativeBearing = com.grigowashere.aismap.utils.NavigationUtils.calculateRelativeBearing(ourVessel.getCourse(), bearing); - if (tvBearing != null) tvBearing.setText("🧭 Пеленг: " + com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing)); + if (tvBearing != null) tvBearing.setText("Пеленг: " + com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing)); } else { - if (tvDistance != null) tvDistance.setText("📏 Расстояние: --"); - if (tvBearing != null) tvBearing.setText("🧭 Пеленг: --"); + if (tvDistance != null) tvDistance.setText("Расстояние: --"); + if (tvBearing != null) tvBearing.setText("Пеленг: --"); } } if (tvTimeAgo != null) { if (vessel.getLastUpdate() != null) { long secondsAgo = java.time.Duration.between(vessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds(); - tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo)); + tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo)); } else { - tvTimeAgo.setText("⏱️ Время назад: --"); + tvTimeAgo.setText("Время назад: --"); } } } @@ -287,7 +290,7 @@ public class BottomSheetsManager { TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago); if (tvTimeAgo != null && currentAISVessel.getLastUpdate() != null) { long secondsAgo = java.time.Duration.between(currentAISVessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds(); - tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo)); + tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo)); } } diff --git a/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java b/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java index fb53d06..a9d4d16 100644 --- a/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java +++ b/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java @@ -4,10 +4,17 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import androidx.core.content.ContextCompat; + +import com.grigowashere.aismap.R; import com.grigowashere.aismap.maps.MapInterface; import com.grigowashere.aismap.maps.MapInterfaceChangeListener; +import com.grigowashere.aismap.maps.MapLibreMapImpl; +import com.grigowashere.aismap.maps.YandexMapImpl; import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.AISVessel; +import com.grigowashere.aismap.utils.GeoUtils; +import com.grigowashere.aismap.utils.SettingsManager; import java.util.HashSet; import java.util.ArrayList; @@ -32,6 +39,8 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac private MapInterface mapInterface; private Handler uiHandler; + private SettingsManager settingsManager; + private android.content.Context appContext; // Pending операции для батчинга private Vessel pendingVesselUpdate; @@ -55,6 +64,16 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac setupThrottling(); Log.i(TAG, "UIRenderingCoordinator инициализирован"); } + + /** + * Передаёт {@link SettingsManager}, чтобы координатор смог отрисовать + * кольца дальности вокруг собственного судна и halo предупреждения у + * целей. Если не вызвать — отрисовка колец не выполняется. + */ + public void setSettingsManager(android.content.Context context, SettingsManager sm) { + this.appContext = context != null ? context.getApplicationContext() : null; + this.settingsManager = sm; + } /** * Настройка throttling механизмов @@ -137,6 +156,7 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac try { Log.d(TAG, "Выполняем vessel update: " + pendingVesselUpdate.getLatitude() + "," + pendingVesselUpdate.getLongitude()); mapInterface.updateOwnVesselPosition(pendingVesselUpdate); + applyRangeRingsAround(pendingVesselUpdate); Log.d(TAG, "Vessel update выполнен успешно"); } catch (Exception e) { Log.e(TAG, "Ошибка vessel update: " + e.getMessage(), e); @@ -144,6 +164,57 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac pendingVesselUpdate = null; } + + /** + * Перерисовывает 3 кольца дальности (опасность/предупреждение/фильтр) + * вокруг собственного судна и сообщает картам параметры warning-зоны для + * подсветки целей. Если кольца отключены в настройках или координаты + * невалидны — кольца очищаются. + */ + private void applyRangeRingsAround(Vessel vessel) { + if (mapInterface == null) return; + if (settingsManager == null || appContext == null) return; + try { + double lat = vessel != null ? vessel.getLatitude() : Double.NaN; + double lon = vessel != null ? vessel.getLongitude() : Double.NaN; + boolean ringsOn = settingsManager.isRangeRingsEnabled() + && GeoUtils.isValidCoordinates(lat, lon); + if (!ringsOn) { + mapInterface.clearOwnShipRangeRings(); + if (mapInterface instanceof MapLibreMapImpl) { + ((MapLibreMapImpl) mapInterface).setWarningZoneParams(lat, lon, 0.0); + } else if (mapInterface instanceof YandexMapImpl) { + ((YandexMapImpl) mapInterface).setWarningZoneParams(lat, lon, 0.0); + } + return; + } + double danger = settingsManager.getDangerRadiusMeters(); + double warning = settingsManager.getWarningRadiusMeters(); + double filter = settingsManager.getFilterRadiusMeters(); + int dangerStroke = ContextCompat.getColor(appContext, R.color.range_ring_danger_stroke); + int dangerFill = ContextCompat.getColor(appContext, R.color.range_ring_danger_fill); + int warningStroke = ContextCompat.getColor(appContext, R.color.range_ring_warning_stroke); + int warningFill = ContextCompat.getColor(appContext, R.color.range_ring_warning_fill); + int filterStroke = ContextCompat.getColor(appContext, R.color.range_ring_filter_stroke); + int filterFill = ContextCompat.getColor(appContext, R.color.range_ring_filter_fill); + double[] radii = new double[] { danger, warning, filter }; + int[] strokes = new int[] { dangerStroke, warningStroke, filterStroke }; + int[] fills = new int[] { dangerFill, warningFill, filterFill }; + boolean[] visible = new boolean[] { + danger > 0.0, + warning > 0.0, + filter > 0.0 && settingsManager.isRangeFilterEnabled() + }; + mapInterface.setOwnShipRangeRings(lat, lon, radii, strokes, fills, visible); + if (mapInterface instanceof MapLibreMapImpl) { + ((MapLibreMapImpl) mapInterface).setWarningZoneParams(lat, lon, warning); + } else if (mapInterface instanceof YandexMapImpl) { + ((YandexMapImpl) mapInterface).setWarningZoneParams(lat, lon, warning); + } + } catch (Throwable t) { + Log.w(TAG, "applyRangeRingsAround: " + t.getMessage()); + } + } /** * Выполнение обновлений AIS судов diff --git a/app/src/main/java/com/grigowashere/aismap/utils/NavigatorZoomMath.java b/app/src/main/java/com/grigowashere/aismap/utils/NavigatorZoomMath.java new file mode 100644 index 0000000..d6c6966 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/utils/NavigatorZoomMath.java @@ -0,0 +1,90 @@ +package com.grigowashere.aismap.utils; + +/** + * Чистая логика зума навигаторской камеры от скорости судна (узлы). + * Не зависит от Android — покрывается unit-тестами без Robolectric. + */ +public final class NavigatorZoomMath { + + private NavigatorZoomMath() { } + + /** + * Линейная интерполяция зума: при {@code speedKnots == 0} — {@code zoomAtZeroSpeed} + * (максимальное приближение), при {@code speedKnots >= maxSpeedKnots} — + * {@code zoomAtMaxSpeed} (максимальное отдаление). + * + * @param speedKnots скорость в узлах (отрицательные трактуются как 0) + * @param zoomAtZeroSpeed зум при нулевой скорости (обычно больше) + * @param zoomAtMaxSpeed зум при максимальной скорости (обычно меньше) + * @param maxSpeedKnots скорость, при которой достигается {@code zoomAtMaxSpeed} + */ + public static float zoomForSpeed(double speedKnots, + float zoomAtZeroSpeed, + float zoomAtMaxSpeed, + float maxSpeedKnots) { + float z0 = clampZoom(zoomAtZeroSpeed); + float zMax = clampZoom(zoomAtMaxSpeed); + if (maxSpeedKnots <= 0f) { + return z0; + } + double speed = speedKnots; + if (speed < 0.0 || Double.isNaN(speed) || Double.isInfinite(speed)) { + speed = 0.0; + } + double t = Math.min(1.0, speed / maxSpeedKnots); + return (float) (z0 + t * (zMax - z0)); + } + + /** + * Ограничивает зум допустимым диапазоном карт (2…20). + */ + public static float clampZoom(float zoom) { + if (Float.isNaN(zoom) || Float.isInfinite(zoom)) { + return 14f; + } + if (zoom < 2f) return 2f; + if (zoom > 20f) return 20f; + return zoom; + } + + /** + * Линейная интерполяция между {@code a} и {@code b} при {@code t} в [0, 1]. + */ + public static double lerp(double a, double b, double t) { + return a + (b - a) * t; + } + + public static float lerp(float a, float b, float t) { + return a + (b - a) * t; + } + + /** + * Ease-out cubic: быстрый старт, плавное завершение. + */ + public static float easeOutCubic(float t) { + if (t <= 0f) return 0f; + if (t >= 1f) return 1f; + float u = 1f - t; + return 1f - u * u * u; + } + + /** + * Плавный поворот по кратчайшей дуге (градусы 0…360). + */ + public static float lerpBearing(float fromDeg, float toDeg, float alpha) { + if (alpha <= 0f) return normalizeBearing360(fromDeg); + if (alpha >= 1f) return normalizeBearing360(toDeg); + float from = normalizeBearing360(fromDeg); + float to = normalizeBearing360(toDeg); + float delta = to - from; + if (delta > 180f) delta -= 360f; + if (delta < -180f) delta += 360f; + return normalizeBearing360(from + delta * alpha); + } + + public static float normalizeBearing360(float deg) { + float x = deg % 360f; + if (x < 0f) x += 360f; + return x; + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java b/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java new file mode 100644 index 0000000..e577bb0 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java @@ -0,0 +1,72 @@ +package com.grigowashere.aismap.utils; + +/** + * Чистые статические утилиты для логики колец дальности. + *

Не зависят от Android — это упрощает покрытие unit-тестами без Robolectric. + */ +public final class RangeMath { + + /** 1 морская миля в метрах. */ + public static final double METERS_PER_NM = 1852.0; + /** 1 километр в метрах. */ + public static final double METERS_PER_KM = 1000.0; + + /** Идентификаторы единиц измерения, совместимые с {@link SettingsManager}. */ + public static final String UNIT_NM = "nm"; + public static final String UNIT_KM = "km"; + + private RangeMath() { } + + /** + * Конвертирует значение в выбранной единице измерения в метры. + *

Любое неизвестное значение единицы трактуется как {@link #UNIT_NM}. + */ + public static double toMeters(double value, String unit) { + if (UNIT_KM.equals(unit)) { + return value * METERS_PER_KM; + } + return value * METERS_PER_NM; + } + + /** + * Возвращает {@code true}, если радиусы колец в порядке возрастания + * и все строго положительны: {@code 0 < danger < warning < filter}. + */ + public static boolean isValidRingOrder(double danger, double warning, double filter) { + return danger > 0.0 && warning > 0.0 && filter > 0.0 + && danger < warning && warning < filter; + } + + /** + * Возвращает {@code true}, если цель находится внутри радиуса фильтра + * относительно собственного судна. Если фильтр выключен или координаты + * собственного/цели некорректны — возвращает {@code true} (цель остаётся). + */ + public static boolean isInsideFilter(boolean filterEnabled, + double filterRadiusMeters, + double ownLat, double ownLon, + double targetLat, double targetLon) { + if (!filterEnabled) return true; + if (!(filterRadiusMeters > 0.0)) return true; + if (Double.isNaN(ownLat) || Double.isNaN(ownLon)) return true; + if (Double.isNaN(targetLat) || Double.isNaN(targetLon)) return true; + double d = haversineMeters(ownLat, ownLon, targetLat, targetLon); + return d <= filterRadiusMeters; + } + + /** + * Расстояние по большому кругу (формула гаверсинуса), м. + * Совпадает с {@link GeoUtils#calculateDistance(double, double, double, double)} + * с точностью до выбора радиуса Земли (используется WGS-84 mean ≈ 6_371_000 m). + */ + public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) { + final double R = 6_371_000.0; + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java index a125845..5f69d4a 100644 --- a/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java +++ b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java @@ -45,13 +45,30 @@ public class SettingsManager { private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level"; /** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */ private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode"; + // Navigator camera (follow own ship + speed-based zoom) + private static final String KEY_NAVIGATOR_CAMERA_ENABLED = "navigator_camera_enabled"; + private static final String KEY_NAVIGATOR_MAX_SPEED_KNOTS = "navigator_max_speed_knots"; + private static final String KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED = "navigator_zoom_at_zero_speed"; + private static final String KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED = "navigator_zoom_at_max_speed"; + private static final String KEY_NAVIGATOR_CAMERA_TRANSITION_MS = "navigator_camera_transition_ms"; // BLE/NMEA settings private static final String KEY_BLE_ENABLED = "ble_enabled"; private static final String KEY_BLE_DEVICE_MAC = "ble_device_mac"; private static final String KEY_BLE_UDP_BRIDGE_ENABLED = "ble_udp_bridge_enabled"; private static final String KEY_BLE_UDP_BRIDGE_HOST = "ble_udp_bridge_host"; private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port"; - + /** Включает чтение battery 0x2A19. По умолчанию выключено: на ряде хабов + * чтение этой характеристики триггерит запрос сопряжения каждые 10 секунд. */ + private static final String KEY_BLE_READ_BATTERY_ENABLED = "ble_read_battery_enabled"; + + // ===== Range rings around own ship ===== + private static final String KEY_RANGE_RINGS_ENABLED = "range_rings_enabled"; + private static final String KEY_RANGE_UNIT = "range_unit"; + private static final String KEY_RANGE_DANGER = "range_danger"; + private static final String KEY_RANGE_WARNING = "range_warning"; + private static final String KEY_RANGE_FILTER = "range_filter"; + private static final String KEY_RANGE_FILTER_ENABLED = "range_filter_enabled"; + // Значения по умолчанию private static final int DEFAULT_UDP_PORT = 10110; private static final boolean DEFAULT_UDP_ENABLED = true; @@ -84,6 +101,24 @@ public class SettingsManager { private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false; private static final String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255"; private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110; + private static final boolean DEFAULT_BLE_READ_BATTERY_ENABLED = false; + + // Range rings defaults + private static final boolean DEFAULT_RANGE_RINGS_ENABLED = true; + private static final String DEFAULT_RANGE_UNIT = "nm"; // "nm" | "km" + private static final float DEFAULT_RANGE_DANGER = 0.5f; + private static final float DEFAULT_RANGE_WARNING = 1.5f; + private static final float DEFAULT_RANGE_FILTER = 5.0f; + private static final boolean DEFAULT_RANGE_FILTER_ENABLED = true; + + // Range unit constants + public static final String RANGE_UNIT_NM = "nm"; + public static final String RANGE_UNIT_KM = "km"; + + /** 1 морская миля в метрах. */ + private static final double METERS_PER_NM = 1852.0; + /** 1 километр в метрах. */ + private static final double METERS_PER_KM = 1000.0; // Режимы работы с данными public static final String DATA_MODE_HYBRID = "hybrid"; @@ -107,6 +142,11 @@ public class SettingsManager { /** Как курс (COG / GPS bearing). */ public static final String MAP_ROTATION_COURSE = "course"; private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL; + private static final boolean DEFAULT_NAVIGATOR_CAMERA_ENABLED = false; + private static final float DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS = 20f; + private static final float DEFAULT_NAVIGATOR_ZOOM_AT_ZERO_SPEED = 18f; + private static final float DEFAULT_NAVIGATOR_ZOOM_AT_MAX_SPEED = 10f; + private static final int DEFAULT_NAVIGATOR_CAMERA_TRANSITION_MS = 600; private Context context; private SharedPreferences prefs; @@ -454,6 +494,63 @@ public class SettingsManager { setMapRotationMode(next); return next; } + + // ===== Navigator camera ===== + + public boolean isNavigatorCameraEnabled() { + return prefs.getBoolean(KEY_NAVIGATOR_CAMERA_ENABLED, DEFAULT_NAVIGATOR_CAMERA_ENABLED); + } + + public void setNavigatorCameraEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_NAVIGATOR_CAMERA_ENABLED, enabled).apply(); + Log.i(TAG, "Навигаторская камера: " + (enabled ? "включена" : "выключена")); + } + + public float getNavigatorMaxSpeedKnots() { + float v = prefs.getFloat(KEY_NAVIGATOR_MAX_SPEED_KNOTS, DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS); + if (v < 1f) v = DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS; + return v; + } + + public void setNavigatorMaxSpeedKnots(float knots) { + if (knots < 1f) knots = DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS; + prefs.edit().putFloat(KEY_NAVIGATOR_MAX_SPEED_KNOTS, knots).apply(); + } + + public float getNavigatorZoomAtZeroSpeed() { + return clampNavigatorZoom(prefs.getFloat(KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED, DEFAULT_NAVIGATOR_ZOOM_AT_ZERO_SPEED)); + } + + public void setNavigatorZoomAtZeroSpeed(float zoom) { + prefs.edit().putFloat(KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED, clampNavigatorZoom(zoom)).apply(); + } + + public float getNavigatorZoomAtMaxSpeed() { + return clampNavigatorZoom(prefs.getFloat(KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED, DEFAULT_NAVIGATOR_ZOOM_AT_MAX_SPEED)); + } + + public void setNavigatorZoomAtMaxSpeed(float zoom) { + prefs.edit().putFloat(KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED, clampNavigatorZoom(zoom)).apply(); + } + + public long getNavigatorCameraTransitionMs() { + int ms = prefs.getInt(KEY_NAVIGATOR_CAMERA_TRANSITION_MS, DEFAULT_NAVIGATOR_CAMERA_TRANSITION_MS); + if (ms < 0) ms = 0; + if (ms > 5000) ms = 5000; + return ms; + } + + public void setNavigatorCameraTransitionMs(int ms) { + if (ms < 0) ms = 0; + if (ms > 5000) ms = 5000; + prefs.edit().putInt(KEY_NAVIGATOR_CAMERA_TRANSITION_MS, ms).apply(); + } + + private static float clampNavigatorZoom(float zoom) { + if (zoom < 2f) return 2f; + if (zoom > 20f) return 20f; + return zoom; + } /** * Проверяет, нужно ли перезапустить UDP слушатель @@ -696,5 +793,87 @@ public class SettingsManager { prefs.edit().putBoolean(KEY_SEAMARKS_ENABLED, enabled).apply(); Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены")); } - + + // ===== BLE battery opt-in ===== + public boolean isBleReadBatteryEnabled() { + return prefs.getBoolean(KEY_BLE_READ_BATTERY_ENABLED, DEFAULT_BLE_READ_BATTERY_ENABLED); + } + + public void setBleReadBatteryEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_BLE_READ_BATTERY_ENABLED, enabled).apply(); + Log.i(TAG, "BLE read battery: " + (enabled ? "включено" : "выключено")); + } + + // ===== Range rings ===== + public boolean isRangeRingsEnabled() { + return prefs.getBoolean(KEY_RANGE_RINGS_ENABLED, DEFAULT_RANGE_RINGS_ENABLED); + } + + public void setRangeRingsEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_RANGE_RINGS_ENABLED, enabled).apply(); + } + + public String getRangeUnit() { + String v = prefs.getString(KEY_RANGE_UNIT, DEFAULT_RANGE_UNIT); + if (!RANGE_UNIT_NM.equals(v) && !RANGE_UNIT_KM.equals(v)) return DEFAULT_RANGE_UNIT; + return v; + } + + public void setRangeUnit(String unit) { + if (!RANGE_UNIT_NM.equals(unit) && !RANGE_UNIT_KM.equals(unit)) unit = DEFAULT_RANGE_UNIT; + prefs.edit().putString(KEY_RANGE_UNIT, unit).apply(); + } + + public float getRangeDanger() { + return prefs.getFloat(KEY_RANGE_DANGER, DEFAULT_RANGE_DANGER); + } + + public void setRangeDanger(float v) { + prefs.edit().putFloat(KEY_RANGE_DANGER, v).apply(); + } + + public float getRangeWarning() { + return prefs.getFloat(KEY_RANGE_WARNING, DEFAULT_RANGE_WARNING); + } + + public void setRangeWarning(float v) { + prefs.edit().putFloat(KEY_RANGE_WARNING, v).apply(); + } + + public float getRangeFilter() { + return prefs.getFloat(KEY_RANGE_FILTER, DEFAULT_RANGE_FILTER); + } + + public void setRangeFilter(float v) { + prefs.edit().putFloat(KEY_RANGE_FILTER, v).apply(); + } + + public boolean isRangeFilterEnabled() { + return prefs.getBoolean(KEY_RANGE_FILTER_ENABLED, DEFAULT_RANGE_FILTER_ENABLED); + } + + public void setRangeFilterEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_RANGE_FILTER_ENABLED, enabled).apply(); + } + + /** Конвертирует значение в выбранной единице ({@link #getRangeUnit()}) в метры. */ + public double convertRangeToMeters(float value) { + if (RANGE_UNIT_KM.equals(getRangeUnit())) { + return value * METERS_PER_KM; + } + return value * METERS_PER_NM; + } + + public double getDangerRadiusMeters() { + return convertRangeToMeters(getRangeDanger()); + } + + public double getWarningRadiusMeters() { + return convertRangeToMeters(getRangeWarning()); + } + + public double getFilterRadiusMeters() { + return convertRangeToMeters(getRangeFilter()); + } + } diff --git a/app/src/main/java/com/grigowashere/aismap/utils/UiInsetsUtils.java b/app/src/main/java/com/grigowashere/aismap/utils/UiInsetsUtils.java new file mode 100644 index 0000000..ab3273d --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/utils/UiInsetsUtils.java @@ -0,0 +1,34 @@ +package com.grigowashere.aismap.utils; + +import android.view.View; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +/** + * Паддинги под системные бары для экранов с edge-to-edge (targetSdk 35+). + */ +public final class UiInsetsUtils { + + private UiInsetsUtils() { + } + + /** + * Добавляет к {@code basePaddingPx} отступы status/nav-bar и display cutout. + */ + public static void applySystemBarPadding(View view, int basePaddingPx) { + ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> { + Insets sys = insets.getInsets( + WindowInsetsCompat.Type.systemBars() + | WindowInsetsCompat.Type.displayCutout()); + v.setPadding( + basePaddingPx + sys.left, + basePaddingPx + sys.top, + basePaddingPx + sys.right, + basePaddingPx + sys.bottom); + return insets; + }); + ViewCompat.requestApplyInsets(view); + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java index eb44f34..c79b41d 100644 --- a/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java +++ b/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java @@ -21,10 +21,52 @@ public abstract class BaseDockWidget extends FrameLayout { protected static final float MIN_SCALE = 0.5f; protected static final float MAX_SCALE = 2.0f; protected static final float SCALE_STEP = 0.1f; + + /** + * Высота в dock-режиме «по умолчанию» (в dp). Используется как fallback, + * если наследник НЕ переопределил {@link #measureDockContentHeightPx(int)}. + * Большинству виджетов достаточно переопределить только measure-метод. + */ + protected int getDefaultDockHeightDp() { + return DEFAULT_DOCK_HEIGHT_DP; + } + + /** + * Сколько пикселей ПОЛЕЗНОГО КОНТЕНТА (без учёта системных паддингов под + * статус-/нав-бар) нужно этому виджету при данной ширине, чтобы корректно + * нарисоваться в dock-режиме. Возвращаемое значение используется в + * {@link #onMeasure(int, int)} как высота content-области. + * + *

Наследники переопределяют этот метод и считают высоту по своим + * реальным метрикам отрисовки (размер шрифта, число строк, и т.п.), чтобы + * не быть привязанными к магической константе. + * + *

По умолчанию возвращает {@code dp(getDefaultDockHeightDp())} — для + * обратной совместимости с виджетами, которые ещё не реализовали measure. + */ + protected int measureDockContentHeightPx(int widthPx) { + return (int) dp(getDefaultDockHeightDp()); + } + + /** + * Куда виджет «прикипает» по умолчанию: {@code true} — к верху экрана, + * {@code false} — к низу. Влияет на: + *

    + *
  • зону resize (верх/низ виджета),
  • + *
  • сторону, к которой подъезжают другие dock-виджеты при стакинге,
  • + *
  • позицию docking после ручного перетаскивания (если пользователь + * отпустил виджет в середине, мы возвращаем его на «домашнюю» сторону).
  • + *
+ * XML-якорь ({@code layout_alignParentBottom} / {@code layout_above}) задаёт + * стартовое положение визуально, а этот метод — внутреннюю модель. + */ + protected boolean getDefaultDockTop() { + return true; + } // Состояние виджета protected boolean isDocked = true; // По умолчанию в dock-режиме - protected boolean dockTop = true; + protected boolean dockTop = true; // Инициализируется в init() через getDefaultDockTop() protected boolean isMorphing = false; protected float morphProgress = 0.0f; // 0 = dock, 1 = circle @@ -72,22 +114,19 @@ public abstract class BaseDockWidget extends FrameLayout { private void init() { setClickable(true); setFocusable(true); - - // Инициализируем в dock-режиме - post(() -> { - if (isDocked) { - ViewGroup parent = (ViewGroup) getParent(); - if (parent != null) { - setX(0); - setY(0); - ViewGroup.LayoutParams lp = getLayoutParams(); - lp.width = ViewGroup.LayoutParams.MATCH_PARENT; - lp.height = (int) dp(DEFAULT_DOCK_HEIGHT_DP); - dockHeightPx = 0; // Сбрасываем сохраненную высоту - setLayoutParams(lp); - } - } - }); + + // Стартовая сторона дока (top/bottom) определяется наследником. Само + // фактическое положение задаёт RelativeLayout (alignParentTop / Bottom / + // layout_above), а этот флаг — внутренняя модель для resize-зоны и + // стакинга других dock-виджетов. + this.dockTop = getDefaultDockTop(); + // Высота view в dock-режиме считается в onMeasure через + // measureDockContentHeightPx(...) при lp.height=WRAP_CONTENT (это + // прописано в activity_main.xml). А переход dock<->circle сам выставляет + // правильные lp в конце анимации (см. setDocked). Намеренно НЕ дёргаем + // setLayoutParams() из init().post() — это вызывало второй проход layout + // ПОСЛЕ первого measure и оставляло координатный/danger виджет с нулевой + // высотой на первый кадр, пока не приходил какой-нибудь size-update. } @Override @@ -232,7 +271,7 @@ public abstract class BaseDockWidget extends FrameLayout { // Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets // прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает // под системный бар даже при минимальном размере. - int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP); + int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(getDefaultDockHeightDp()); int newHeight = currentContent; if (dockTop) { @@ -309,7 +348,7 @@ public abstract class BaseDockWidget extends FrameLayout { // При докинге всегда устанавливаем размер по умолчанию dockHeightPx = 0; // Сбрасываем сохраненную высоту - setDocked(true, dockToTop, 0f, dockToTop ? 0f : screenHeight - dp(DEFAULT_DOCK_HEIGHT_DP)); + setDocked(true, dockToTop, 0f, dockToTop ? 0f : screenHeight - dp(getDefaultDockHeightDp())); } private float getDistance(MotionEvent event) { @@ -324,10 +363,16 @@ public abstract class BaseDockWidget extends FrameLayout { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (isDocked) { int width = MeasureSpec.getSize(widthMeasureSpec); - // dockHeightPx/DEFAULT — это высота полезного контента; к ней - // прибавляем padding от WindowInsets, чтобы виджет фактически - // расширялся под статус-бар или нав-бар и не прятал контент. - int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP); + // Высота content-области: + // * если пользователь ВРУЧНУЮ растянул виджет (dockHeightPx>0) — + // используем эту фиксированную высоту; + // * иначе спрашиваем у конкретного виджета через measure-метод, + // сколько ему нужно для отрисовки на данной ширине. + // Системные паддинги (статус-бар/нав-бар) прибавляются СВЕРХУ + // content-области, чтобы карта не пряталась под бары. + int content = dockHeightPx > 0 + ? dockHeightPx + : measureDockContentHeightPx(width); int height = content + getPaddingTop() + getPaddingBottom(); setMeasuredDimension(width, height); } else { @@ -353,7 +398,15 @@ public abstract class BaseDockWidget extends FrameLayout { } public void setDocked(boolean docked, boolean top, float targetX, float targetY) { - if (this.isDocked == docked && this.dockTop == top && getX() == targetX && getY() == targetY) { + // Раннее завершение опираем ТОЛЬКО на isDocked/dockTop. Позицию dock-виджета + // задаёт RelativeLayout (alignParentTop / alignParentBottom / layout_above), + // а не translationX/Y. Сравнение с getX()/getY() сюда подмешивало проблему: + // если кто-то делал coordinatesWidget.post(() -> setDocked(true, false, 0, 0)) + // ПОСЛЕ первого layout, getY() уже был = parent.bottom-h ≠ 0, ранний return + // не срабатывал, и анимация уезжала к computed-position через setX/setY, + // оставляя translation-ом виджет за экраном. Теперь повторный setDocked + // в ту же сторону — это явный no-op. + if (this.isDocked == docked && this.dockTop == top) { return; } @@ -372,7 +425,7 @@ public abstract class BaseDockWidget extends FrameLayout { ViewGroup parent = (ViewGroup) getParent(); int parentWidth = parent.getWidth(); int parentHeight = parent.getHeight(); - int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP); + int dockHeight = (int) dp(getDefaultDockHeightDp()); int circleSize = (int) dp(CIRCLE_SIZE_DP); int endW = docked ? parentWidth : circleSize; @@ -422,16 +475,34 @@ public abstract class BaseDockWidget extends FrameLayout { @Override public void onAnimationEnd(Animator animation) { ViewGroup.LayoutParams lp = getLayoutParams(); - lp.width = endW; - lp.height = endH; - setLayoutParams(lp); - - setX(finalEndX); - setY(finalEndY); + if (docked) { + // В dock-режиме высоту контролирует measureDockContentHeightPx, + // ширину — родитель. Если оставить фиксированные endW/endH + // от анимации, виджет навсегда «застрянет» в этом размере + // и нарушит формулу контент+паддинги. + lp.width = ViewGroup.LayoutParams.MATCH_PARENT; + lp.height = ViewGroup.LayoutParams.WRAP_CONTENT; + setLayoutParams(lp); + // Финальную позицию dock-виджета задаёт RelativeLayout + // (alignParentTop/Bottom/layout_above). Translation от + // анимации тут лишний — он бы «прибил» виджет к + // animation-target, ломая layout-правила (например, после + // ресайза/вставки insets) и мог увести его за экран. + setTranslationX(0f); + setTranslationY(0f); + } else { + lp.width = endW; + lp.height = endH; + setLayoutParams(lp); + // В circle-режиме виджет «свободно плавает», его позицию + // мы держим именно через translation (setX/setY). + setX(finalEndX); + setY(finalEndY); + } morphProgress = endMorph; - + postInvalidateOnAnimation(); - + isMorphing = false; } }); @@ -477,7 +548,7 @@ public abstract class BaseDockWidget extends FrameLayout { ViewGroup parent = (ViewGroup) getParent(); if (parent == null) return 0; - int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP); + int dockHeight = (int) dp(getDefaultDockHeightDp()); float y = 0; if (dockTop) { @@ -518,43 +589,30 @@ public abstract class BaseDockWidget extends FrameLayout { } /** - * Перепозиционирует все docked виджеты, чтобы они прижались к краям + * Сбрасывает translation у всех dock-виджетов, чтобы их положение + * определялось layout-правилами родителя (alignParentTop / alignParentBottom / + * layout_above), а не остаточной анимационной трансляцией. + * + * Раньше этот метод сам считал Y по getHeight() и звал setY(...) — это + * генерировало translationY ≠ 0 поверх RelativeLayout-ов, и виджеты после + * dock-state-change «прилипали» к старым координатам (вплоть до ухода за + * экран, если parent ещё не успел измериться). Теперь мы доверяем layout-у + * и только обнуляем translation, чтобы visual position == layout position. */ public static void repositionAllDockedWidgets(ViewGroup parent) { if (parent == null) return; - - // Собираем все docked виджеты сверху - java.util.List topWidgets = new java.util.ArrayList<>(); - java.util.List bottomWidgets = new java.util.ArrayList<>(); - + for (int i = 0; i < parent.getChildCount(); i++) { View child = parent.getChildAt(i); if (child instanceof BaseDockWidget) { BaseDockWidget widget = (BaseDockWidget) child; - if (widget.isDocked()) { - if (widget.isDockTop()) { - topWidgets.add(widget); - } else { - bottomWidgets.add(widget); - } + if (widget.isDocked() && !widget.isMorphing) { + widget.setTranslationX(0f); + widget.setTranslationY(0f); } } } - - // Перепозиционируем виджеты сверху - float currentY = 0; - for (BaseDockWidget widget : topWidgets) { - widget.setY(currentY); - currentY += widget.getHeight(); - } - - // Перепозиционируем виджеты снизу - currentY = parent.getHeight(); - for (int i = bottomWidgets.size() - 1; i >= 0; i--) { - BaseDockWidget widget = bottomWidgets.get(i); - currentY -= widget.getHeight(); - widget.setY(currentY); - } + parent.requestLayout(); } // Абстрактные методы для переопределения в наследниках diff --git a/app/src/main/java/com/grigowashere/aismap/view/CompassView.java b/app/src/main/java/com/grigowashere/aismap/view/CompassView.java index a0aa665..8cc5ac6 100644 --- a/app/src/main/java/com/grigowashere/aismap/view/CompassView.java +++ b/app/src/main/java/com/grigowashere/aismap/view/CompassView.java @@ -65,6 +65,29 @@ public class CompassView extends BaseDockWidget { init(); } + /** + * Минимальная высота контента, при которой шкала компаса и её буквы N/S/W/E + * гарантированно помещаются в видимую область. + * + *

Считаем по факту отрисовки: + *

    + *
  • header (HEADING/MAG label+value+divider) ≈ 38dp,
  • + *
  • шкала с буквами по краям ≈ 56dp.
  • + *
+ * Итого ≈ 94dp полезного контента; ставим 96dp с запасом на baselines. + */ + private static final int CONTENT_HEIGHT_DP = 96; + + @Override + protected int getDefaultDockHeightDp() { + return CONTENT_HEIGHT_DP; + } + + @Override + protected int measureDockContentHeightPx(int widthPx) { + return (int) dp(CONTENT_HEIGHT_DP); + } + private void init() { paint.setColor(TICK_COLOR); paint.setTextAlign(Paint.Align.CENTER); @@ -156,40 +179,36 @@ public class CompassView extends BaseDockWidget { // чтобы под статус-бар/бровь тоже уходил единый тон. canvas.drawRect(0, 0, totalW, totalH, bgPaint); - // Масштабируем размеры в зависимости от высоты контентной области. - float baseHeight = dp(80); - float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight)); - // Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в // координатах): слева HEADING (азимут), справа MAG (магн. компас). - float cx = left + w / 2f; + // Размеры шапки фиксированы и не зависят от высоты виджета — это + // обычные строчки текста, они и так хорошо смотрятся при любой высоте. float padInner = dp(10); - float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f); - float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f); + float labelY = top + dp(12); + float valueY = labelY + dp(16); labelPaint.setTextAlign(Paint.Align.LEFT); valuePaint.setTextAlign(Paint.Align.LEFT); accentPaint.setTextAlign(Paint.Align.LEFT); - canvas.drawText("HEADING", left + padInner, labelY, labelPaint); - canvas.drawText(((int) currentAzimuth) + "°", + canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading), + left + padInner, labelY, labelPaint); + canvas.drawText(((int) currentAzimuth) + "\u00B0", left + padInner, valueY, accentPaint); labelPaint.setTextAlign(Paint.Align.RIGHT); valuePaint.setTextAlign(Paint.Align.RIGHT); - canvas.drawText("MAG", right - padInner, labelY, labelPaint); - canvas.drawText(((int) magneticCompass) + "°", + canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_mag), + right - padInner, labelY, labelPaint); + canvas.drawText(((int) magneticCompass) + "\u00B0", right - padInner, valueY, valuePaint); - // Разделитель под шапкой — такой же, как в координатах. float dividerY = valueY + dp(6); canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint); - // Цвет делений шкалы — светло-серый, чтобы не спорил с фоном палитры. paint.setColor(TICK_COLOR); - paint.setTextSize(24 * scaleFactor); paint.setTextAlign(Paint.Align.CENTER); - + // Плавное обновление азимута float diff = getShortestRotation(currentAzimuth, targetAzimuth); if (Math.abs(diff) > AZIMUTH_DRAW_EPS) { @@ -199,59 +218,67 @@ public class CompassView extends BaseDockWidget { currentAzimuth = normalizeAngle(currentAzimuth); postInvalidateOnAnimation(); } - - // Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала - // не наезжала на label-строку HEADING/MAG. + + // === Шкала компаса === + // ВСЕ метрики шкалы выражены в долях от фактической высоты scaleH — + // тогда буквы N/S/W/E и градусные подписи никогда не вылезают за + // нижнюю границу виджета, даже если пользователь сжал его ручкой. float centerX = left + w / 2f; float scaleTop = dividerY + dp(4); - float centerY = scaleTop + (bottom - scaleTop) * 0.5f; + float scaleH = Math.max(dp(28), bottom - scaleTop); + float centerY = scaleTop + scaleH * 0.5f; + + // Размеры тиков и подписей — фракции от scaleH. + float majorTickH = scaleH * 0.18f; + float minorTickH = scaleH * 0.09f; + float degreeTextY = centerY - scaleH * 0.32f; // подпись 0/30/60... над центром + float letterBaseY = centerY + scaleH * 0.40f; // буквы N/E/S/W под центром + float degreeTextSize = Math.max(dp(8), scaleH * 0.22f); + float visibleDegrees = 120; - - // Рисуем деления шкалы for (int degree = 0; degree < 360; degree += 15) { - // Вычисляем относительное положение деления float relativeDegree = getShortestRotation(currentAzimuth, degree); - - // Рисуем только видимые деления - if (Math.abs(relativeDegree) <= visibleDegrees / 2) { - float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2); - float lineHeight = (degree % 30 == 0) ? 20 * scaleFactor : 10 * scaleFactor; - canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint); - - if (degree % 30 == 0) { - String degreeText = String.valueOf(degree); - paint.setTextSize(16 * scaleFactor); - canvas.drawText(degreeText, x, centerY - 30 * scaleFactor, paint); - } - if (degree % 45 == 0) { - int directionIndex = (degree / 45) % 8; - if (directionIndex < directions.length) { - // Буква стороны света увеличивается при приближении к центру - float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f); - float letterSize = (24f + 36f * proximity) * scaleFactor; // 24..48 - paint.setTextSize(letterSize); - canvas.drawText(directions[directionIndex], x, centerY + 50 * scaleFactor, paint); - } + if (Math.abs(relativeDegree) > visibleDegrees / 2) continue; + + float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2); + float lineHeight = (degree % 30 == 0) ? majorTickH : minorTickH; + canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint); + + if (degree % 30 == 0) { + paint.setTextSize(degreeTextSize); + canvas.drawText(String.valueOf(degree), x, degreeTextY, paint); + } + if (degree % 45 == 0) { + int directionIndex = (degree / 45) % 8; + if (directionIndex < directions.length) { + // Буква стороны света увеличивается при приближении к центру. + float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f); + // На краях ~0.35*scaleH, в центре ~0.7*scaleH — никогда не больше scaleH. + float letterSize = scaleH * (0.35f + 0.35f * proximity); + paint.setTextSize(letterSize); + canvas.drawText(directions[directionIndex], x, letterBaseY, paint); } } } - - // Рисуем суда + + // Рисуем суда: размер тоже скейлим по scaleH. + float vesselScale = scaleH / dp(60); // 1.0 при scaleH=60dp + vesselScale = Math.max(0.6f, Math.min(1.6f, vesselScale)); for (AISVessel vessel : nearbyVessels) { float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse()); if (Math.abs(relativeBearing) <= visibleDegrees / 2) { float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2); double distance = ourVessel != null ? GeoUtils.calculateDistance(ourVessel, vessel) : 0; - float size = calculateVesselSize((float) distance) * scaleFactor; + float size = calculateVesselSize((float) distance) * vesselScale; vesselPaint.setColor(getVesselColor(vessel)); drawVesselTriangle(canvas, x, centerY, size, (float) (vessel.getCourse() - currentAzimuth)); } } - + // Центральная линия (направление вперёд) — только в области шкалы, // чтобы не пересекать шапку HEADING/MAG. paint.setColor(Color.RED); - paint.setStrokeWidth(3 * scaleFactor); + paint.setStrokeWidth(Math.max(2f, scaleH * 0.05f)); canvas.drawLine(centerX, scaleTop, centerX, bottom, paint); paint.setColor(TICK_COLOR); paint.setStrokeWidth(1); @@ -360,7 +387,8 @@ public class CompassView extends BaseDockWidget { canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint); labelPaint.setTextAlign(Paint.Align.CENTER); labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor))); - canvas.drawText("HEADING", cx, cy + dp(14), labelPaint); + canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading), + cx, cy + dp(14), labelPaint); } diff --git a/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java index d7cfa9c..36ee379 100644 --- a/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java +++ b/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java @@ -45,7 +45,38 @@ public class CoordinatesDockWidget extends BaseDockWidget { super(context, attrs); init(); } - + + @Override + protected int getDefaultDockHeightDp() { + // Fallback на случай, если по какой-то причине measureDockContentHeightPx + // не сработает. Реальная высота считается через measureDockContentHeightPx. + return 88; + } + + @Override + protected boolean getDefaultDockTop() { + return false; + } + + @Override + protected int measureDockContentHeightPx(int widthPx) { + // Контент состоит из 2 строк «label + value»: + // 1) POSITION + координаты, + // 2) SOG | COG | ACC. + // Каждая строка — labelH + valueH, плюс паддинги 8dp сверху/снизу и + // зазор 10dp между строками. Размеры берём ровно из тех Paint'ов, + // которыми отрисовка пользуется — это гарантирует, что любая правка + // размера шрифта автоматически подстроит высоту виджета. + float labelH = (labelPaint != null ? labelPaint.getTextSize() : dp(11)) * 1.1f; + float valueH = (textPaint != null ? textPaint.getTextSize() : dp(16)) * 1.15f; + float total = dp(8) // верхний внутренний отступ + + labelH + valueH // строка 1: POSITION + + dp(10) // зазор между блоками + + labelH + valueH // строка 2: SOG/COG/ACC + + dp(8); // нижний внутренний отступ + return (int) Math.ceil(total); + } + private void init() { backgroundPaint = new Paint(); backgroundPaint.setColor(BACKGROUND_COLOR); @@ -125,7 +156,7 @@ public class CoordinatesDockWidget extends BaseDockWidget { if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) { coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude()); } else { - coordinatesText = "нет фикса"; + coordinatesText = getResources().getString(com.grigowashere.aismap.R.string.coords_value_no_fix); } if (vessel.getSpeed() > 0.05) { @@ -195,34 +226,40 @@ public class CoordinatesDockWidget extends BaseDockWidget { float innerTop = top + dp(8); float innerBottom = bottom - dp(8); - // Строка 1: координаты (с подписью "POSITION"). + // Строка 1: координаты (с подписью "КООРДИНАТЫ"). Paint posPaint = getCoordinatesPaint(); float labelH = labelPaint.getTextSize() * 1.1f; float valueH = posPaint.getTextSize() * 1.15f; + android.content.res.Resources res = getResources(); + float y = innerTop + labelH; - canvas.drawText("POSITION", innerLeft, y, labelPaint); + canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_position), + innerLeft, y, labelPaint); y += valueH; canvas.drawText(coordinatesText, innerLeft, y, posPaint); - // Строка 2: SOG | COG | ACC в три колонки. + // Строка 2: SOG/COG/ACC в три колонки. float colTop = y + dp(10); float colW = (innerRight - innerLeft) / 3f; float colLabelY = colTop + labelH; float colValueY = colLabelY + valueH; - // SOG - canvas.drawText("SOG", innerLeft, colLabelY, labelPaint); + // SOG (скорость). + canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_sog), + innerLeft, colLabelY, labelPaint); canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint()); - // COG + // COG (курс по земле). float cogX = innerLeft + colW; - canvas.drawText("COG", cogX, colLabelY, labelPaint); + canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_cog), + cogX, colLabelY, labelPaint); canvas.drawText(cogText, cogX, colValueY, getCOGPaint()); - // ACC + // ACC (точность). float accX = innerLeft + colW * 2f; - canvas.drawText("ACC", accX, colLabelY, labelPaint); + canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_acc), + accX, colLabelY, labelPaint); canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint()); if (colValueY > innerBottom) { @@ -278,7 +315,8 @@ public class CoordinatesDockWidget extends BaseDockWidget { float y = centerY - totalH / 2f + smallLabel; - drawCentered(canvas, "POSITION", centerX, y, labelPaint); + drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_position), + centerX, y, labelPaint); y += lineH; drawCentered(canvas, latLine, centerX, y, posPaint); y += lineH; @@ -291,14 +329,17 @@ public class CoordinatesDockWidget extends BaseDockWidget { // SOG / COG бок о бок. float colCenterL = centerX - radius * 0.45f; float colCenterR = centerX + radius * 0.45f; - drawCentered(canvas, "SOG", colCenterL, y, labelPaint); - drawCentered(canvas, "COG", colCenterR, y, labelPaint); + drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_sog), + colCenterL, y, labelPaint); + drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_cog), + colCenterR, y, labelPaint); y += bigValue + lineGap; drawCentered(canvas, sogText, colCenterL, y, sogPaint); drawCentered(canvas, cogText, colCenterR, y, cogPaint); y += dp(6); - drawCentered(canvas, "ACC", centerX, y, labelPaint); + drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_acc), + centerX, y, labelPaint); y += smallValue + lineGap; drawCentered(canvas, accuracyText, centerX, y, accPaint); diff --git a/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java new file mode 100644 index 0000000..063eb48 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java @@ -0,0 +1,345 @@ +package com.grigowashere.aismap.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.util.AttributeSet; + +import com.grigowashere.aismap.R; +import com.grigowashere.aismap.controllers.AppCoordinator; +import com.grigowashere.aismap.utils.SettingsManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * Виджет «Опасные цели» — таблица ближайших AIS-целей в зоне опасности. + * Колонки: имя/MMSI, пеленг (°), дистанция (nm/km — в зависимости от настроек). + *

Обновление выполняется через {@link #setEntries(List)} с частотой 1 Hz из + * MainActivity, источник данных — {@link AppCoordinator#getDangerTargets(double, int)}. + */ +public class DangerTargetsDockWidget extends BaseDockWidget { + + /** Запись таблицы: имя/MMSI + пеленг + дистанция в метрах. */ + public static final class DangerEntry { + public final String name; + public final double bearingDeg; + public final double distanceMeters; + + public DangerEntry(String name, double bearingDeg, double distanceMeters) { + this.name = name; + this.bearingDeg = bearingDeg; + this.distanceMeters = distanceMeters; + } + } + + private static final int MAX_ROWS = 5; + + /** + * Высота dock-режима по умолчанию (dp). Достаточная, чтобы вместить заголовок + * и 2 строки целей мелким шрифтом. Пользователь может растянуть ручкой за + * нижний край, если хочет увидеть больше строк. + */ + private static final int DEFAULT_DOCK_HEIGHT_DP_DANGER = 72; + + private static final int BACKGROUND_COLOR = 0xD92A1A1A; + private static final int LABEL_COLOR = 0xFFE0B0B0; + private static final int TEXT_COLOR = 0xFFFFFFFF; + private static final int ACCENT_COLOR = 0xFFFF6B6B; + + private Paint backgroundPaint; + private Paint titlePaint; + private Paint labelPaint; + private Paint textPaint; + private Paint accentPaint; + private Paint dividerPaint; + + private final List entries = Collections.synchronizedList(new ArrayList<>()); + private SettingsManager settingsManager; + + public DangerTargetsDockWidget(Context context) { + super(context); + init(); + } + + public DangerTargetsDockWidget(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + @Override + protected int getDefaultDockHeightDp() { + return DEFAULT_DOCK_HEIGHT_DP_DANGER; + } + + @Override + protected boolean getDefaultDockTop() { + // Виджет «Опасные цели» по умолчанию сидит ВНИЗУ над координатами — + // это самая безболезненная для карты позиция: при отсутствии целей + // он вообще скрыт, а когда появляется, не разрывает обзор по центру. + return false; + } + + @Override + protected int measureDockContentHeightPx(int widthPx) { + // Высота зависит от количества опасных целей: + // * 1 строка → ~52dp + // * 2 строки → ~70dp + // * 3 строки → ~88dp + // * 4 строки → ~106dp + // * 5 строк → ~124dp + // Когда целей нет — виджет вообще GONE (см. MainActivity.updateDangerWidget), + // но если попадём сюда — считаем минимальную высоту с пустой строкой. + int count; + synchronized (entries) { + count = entries.size(); + } + float titleH = (titlePaint != null ? titlePaint.getTextSize() : dp(11)); + float rowH = (textPaint != null ? textPaint.getTextSize() : dp(12)) + dp(6); + float emptyH = (labelPaint != null ? labelPaint.getTextSize() : dp(10)); + + // top pad + title + (rowsH ИЛИ empty-строка) + bottom pad + float total; + if (count <= 0) { + total = dp(4) + titleH + dp(6) + emptyH + dp(8); + } else { + total = dp(4) + titleH + dp(6) + rowH * count + dp(8); + } + // Гарантируем минимум 52dp — иначе на 1 цель текст касается рамки. + return (int) Math.ceil(Math.max(dp(52), total)); + } + + private void init() { + settingsManager = new SettingsManager(getContext()); + + backgroundPaint = new Paint(); + backgroundPaint.setColor(BACKGROUND_COLOR); + backgroundPaint.setStyle(Paint.Style.FILL); + backgroundPaint.setAntiAlias(true); + + titlePaint = new Paint(); + titlePaint.setColor(ACCENT_COLOR); + titlePaint.setTextSize(dp(11)); + titlePaint.setTypeface(Typeface.DEFAULT_BOLD); + titlePaint.setLetterSpacing(0.08f); + titlePaint.setAntiAlias(true); + + labelPaint = new Paint(); + labelPaint.setColor(LABEL_COLOR); + labelPaint.setTextSize(dp(10)); + labelPaint.setTypeface(Typeface.DEFAULT); + labelPaint.setLetterSpacing(0.04f); + labelPaint.setAntiAlias(true); + + textPaint = new Paint(); + textPaint.setColor(TEXT_COLOR); + textPaint.setTextSize(dp(12)); + textPaint.setTypeface(Typeface.DEFAULT_BOLD); + textPaint.setAntiAlias(true); + + accentPaint = new Paint(); + accentPaint.setColor(ACCENT_COLOR); + accentPaint.setTextSize(dp(12)); + accentPaint.setTypeface(Typeface.DEFAULT_BOLD); + accentPaint.setAntiAlias(true); + + dividerPaint = new Paint(); + dividerPaint.setColor(0x33FFFFFF); + dividerPaint.setStrokeWidth(dp(1)); + dividerPaint.setAntiAlias(true); + + setBackgroundColor(android.graphics.Color.TRANSPARENT); + } + + /** + * Обновляет список опасных целей, пересчитывает высоту виджета + * (через requestLayout — measureDockContentHeightPx зависит от числа целей) + * и перерисовывает контент. + */ + public void setEntries(List newEntries) { + int prevSize; + int newSize; + synchronized (entries) { + prevSize = entries.size(); + entries.clear(); + if (newEntries != null) { + for (DangerEntry e : newEntries) { + if (e != null) entries.add(e); + if (entries.size() >= MAX_ROWS) break; + } + } + newSize = entries.size(); + } + // requestLayout только если число строк реально изменилось — лишний + // measure-pass на 1Hz обновлении содержимого ни к чему. + if (prevSize != newSize) { + requestLayout(); + } + invalidate(); + } + + @Override + protected void onDrawDock(Canvas canvas) { + int width = getWidth(); + int height = getHeight(); + if (width <= 0 || height <= 0) return; + + canvas.drawRect(0, 0, width, height, backgroundPaint); + + float left = getPaddingLeft(); + float top = getPaddingTop(); + float right = width - getPaddingRight(); + float bottom = height - getPaddingBottom(); + if (right - left <= 0 || bottom - top <= 0) return; + + // Тонкая красная полоска вдоль края, ближайшего к карте — она работает как + // акцент и одновременно как resize-зона (см. BaseDockWidget). + Paint edgePaint = new Paint(dividerPaint); + edgePaint.setColor(ACCENT_COLOR); + edgePaint.setStrokeWidth(dp(2)); + if (isDockTop()) { + canvas.drawLine(left, bottom - dp(1), right, bottom - dp(1), edgePaint); + } else { + canvas.drawLine(left, top + dp(1), right, top + dp(1), edgePaint); + } + + float padX = dp(12); + float innerLeft = left + padX; + float innerRight = right - padX; + + // Снапшот текущих записей. + List snapshot; + synchronized (entries) { + snapshot = new ArrayList<>(entries); + } + + // === Заголовок: «Опасные цели · N» === + float titleBaseline = top + dp(4) + titlePaint.getTextSize(); + String titleBase = getResources().getString(R.string.danger_widget_title); + String title = snapshot.isEmpty() + ? titleBase + : titleBase + " \u00B7 " + snapshot.size(); + canvas.drawText(title, innerLeft, titleBaseline, titlePaint); + + // Пустое состояние: рендерим короткое сообщение тонким шрифтом и выходим. + if (snapshot.isEmpty()) { + float emptyBaseline = titleBaseline + labelPaint.getTextSize() + dp(4); + canvas.drawText(getResources().getString(R.string.danger_widget_empty), + innerLeft, emptyBaseline, labelPaint); + return; + } + + // === Строки целей. Колонки: имя | пеленг | дистанция === + // Имя — широкая колонка слева, пеленг и дистанция — справа выровнены по + // фиксированной ширине, чтобы цифры не «прыгали» между строками. + float distMaxWidth = textPaint.measureText("999.99 nm"); + float bearingMaxWidth = textPaint.measureText("000\u00B0"); + float colDistanceRight = innerRight; + float colBearingRight = colDistanceRight - distMaxWidth - dp(10); + float nameRight = colBearingRight - bearingMaxWidth - dp(10); + + boolean useNm = settingsManager == null + || SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit()); + + // Высоту строки считаем по реальному текстовому шрифту. + float rowH = textPaint.getTextSize() + dp(6); + float y = titleBaseline + dp(6) + textPaint.getTextSize(); + for (DangerEntry e : snapshot) { + if (y > bottom - dp(2)) break; + String rawName = (e.name == null || e.name.isEmpty()) ? "\u2014" : e.name; + String name = ellipsize(rawName, textPaint, nameRight - innerLeft - dp(4)); + String bearing = String.format(Locale.US, "%03.0f\u00B0", e.bearingDeg); + String distance = formatDistance(e.distanceMeters, useNm); + + canvas.drawText(name, innerLeft, y, textPaint); + // Пеленг и дистанция — выравнивание справа по своим колонкам. + float bearingWidth = textPaint.measureText(bearing); + canvas.drawText(bearing, colBearingRight - bearingWidth, y, textPaint); + float distanceWidth = accentPaint.measureText(distance); + canvas.drawText(distance, colDistanceRight - distanceWidth, y, accentPaint); + + y += rowH; + } + } + + @Override + protected void onDrawCircle(Canvas canvas) { + int width = getWidth(); + int height = getHeight(); + int centerX = width / 2; + int centerY = height / 2; + int radius = Math.min(width, height) / 2 - (int) dp(4); + + canvas.drawCircle(centerX, centerY, radius, backgroundPaint); + + Paint borderPaint = new Paint(); + borderPaint.setColor(ACCENT_COLOR); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(dp(2)); + borderPaint.setAntiAlias(true); + canvas.drawCircle(centerX, centerY, radius, borderPaint); + + // В compact-режиме показываем только число опасных целей и ближайшую. + int count; + DangerEntry nearest; + synchronized (entries) { + count = entries.size(); + nearest = entries.isEmpty() ? null : entries.get(0); + } + boolean useNm = settingsManager == null + || SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit()); + + Paint countPaint = new Paint(accentPaint); + countPaint.setTextSize(dp(28)); + Paint subPaint = new Paint(textPaint); + subPaint.setTextSize(dp(11)); + + String countStr = String.valueOf(count); + Rect b = new Rect(); + countPaint.getTextBounds(countStr, 0, countStr.length(), b); + canvas.drawText(countStr, centerX - b.width() / 2f - b.left, centerY, countPaint); + + String label = getResources().getString(R.string.danger_widget_title); + b = new Rect(); + subPaint.getTextBounds(label, 0, label.length(), b); + canvas.drawText(label, centerX - b.width() / 2f - b.left, + centerY - dp(28), subPaint); + + if (nearest != null) { + String nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s", + nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm)); + b = new Rect(); + subPaint.getTextBounds(nearestStr, 0, nearestStr.length(), b); + canvas.drawText(nearestStr, centerX - b.width() / 2f - b.left, + centerY + dp(20), subPaint); + } + } + + private static String formatDistance(double meters, boolean useNm) { + if (useNm) { + double nm = meters / 1852.0; + return String.format(Locale.US, "%.2f nm", nm); + } + if (meters >= 1000.0) { + return String.format(Locale.US, "%.2f km", meters / 1000.0); + } + return String.format(Locale.US, "%.0f m", meters); + } + + private static String ellipsize(String text, Paint paint, float maxWidth) { + if (text == null) return ""; + if (maxWidth <= 0) return text; + if (paint.measureText(text) <= maxWidth) return text; + String ellipsis = "\u2026"; + int len = text.length(); + while (len > 0 && paint.measureText(text.substring(0, len) + ellipsis) > maxWidth) { + len--; + } + if (len <= 0) return ellipsis; + return text.substring(0, len) + ellipsis; + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/view/PlotterHeadingView.java b/app/src/main/java/com/grigowashere/aismap/view/PlotterHeadingView.java new file mode 100644 index 0000000..b0d717a --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/view/PlotterHeadingView.java @@ -0,0 +1,147 @@ +package com.grigowashere.aismap.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.grigowashere.aismap.R; + +import java.util.Locale; + +/** + * Компактная роза курса для режима картплоттера (без dock-поведения). + */ +public class PlotterHeadingView extends View { + + private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path needle = new Path(); + + private float headingDeg = 0f; + private float magneticDeg = Float.NaN; + + public PlotterHeadingView(Context context) { + super(context); + init(); + } + + public PlotterHeadingView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + int label = ContextCompat.getColor(getContext(), R.color.plotter_text_label); + int text = ContextCompat.getColor(getContext(), R.color.plotter_text_primary); + int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent); + + labelPaint.setColor(label); + labelPaint.setTextSize(dp(9)); + labelPaint.setLetterSpacing(0.06f); + + valuePaint.setColor(text); + valuePaint.setTextSize(dp(16)); + valuePaint.setFakeBoldText(true); + + ringPaint.setStyle(Paint.Style.STROKE); + ringPaint.setStrokeWidth(dp(1.5f)); + ringPaint.setColor(accent); + + tickPaint.set(ringPaint); + tickPaint.setStrokeWidth(dp(1)); + + needlePaint.setStyle(Paint.Style.FILL); + needlePaint.setColor(accent); + } + + public void setHeading(float headingDeg, float magneticDeg) { + this.headingDeg = headingDeg; + this.magneticDeg = magneticDeg; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) return; + + String hdgLabel = getContext().getString(R.string.radar_plotter_heading_label); + canvas.drawText(hdgLabel, dp(8), dp(12), labelPaint); + + String hdgVal = String.format(Locale.US, "%03.0f\u00B0", normalize(headingDeg)); + canvas.drawText(hdgVal, dp(8), dp(32), valuePaint); + + String mag = null; + if (!Float.isNaN(magneticDeg)) { + mag = String.format(Locale.US, "MAG %03.0f\u00B0", normalize(magneticDeg)); + canvas.drawText(mag, dp(8), dp(48), labelPaint); + } + + float textBlockW = Math.max(labelPaint.measureText(hdgLabel), valuePaint.measureText(hdgVal)); + if (mag != null) { + textBlockW = Math.max(textBlockW, labelPaint.measureText(mag)); + } + float pad = dp(8); + float roseLeft = pad + textBlockW + pad; + float roseRight = w - pad; + float roseTop = pad; + float roseBottom = h - pad; + float availW = Math.max(0f, roseRight - roseLeft); + float availH = Math.max(0f, roseBottom - roseTop); + float cx = roseLeft + availW * 0.5f; + float cy = roseTop + availH * 0.5f; + float r = Math.min(availW, availH) * 0.45f - dp(2); + if (r < dp(8)) { + r = dp(8); + } + + canvas.drawCircle(cx, cy, r, ringPaint); + String[] dirs = {"N", "E", "S", "W"}; + for (int i = 0; i < 4; i++) { + double ang = Math.toRadians(i * 90 - headingDeg); + float tx = cx + (float) (Math.sin(ang) * (r + dp(10))); + float ty = cy - (float) (Math.cos(ang) * (r + dp(10))); + String d = dirs[i]; + canvas.drawText(d, tx - labelPaint.measureText(d) / 2f, ty + dp(4), labelPaint); + } + + for (int deg = 0; deg < 360; deg += 30) { + double ang = Math.toRadians(deg - headingDeg); + float x1 = cx + (float) (Math.sin(ang) * (r - dp(4))); + float y1 = cy - (float) (Math.cos(ang) * (r - dp(4))); + float x2 = cx + (float) (Math.sin(ang) * r); + float y2 = cy - (float) (Math.cos(ang) * r); + canvas.drawLine(x1, y1, x2, y2, tickPaint); + } + + double needleAng = Math.toRadians(-headingDeg); + float nx = cx + (float) (Math.sin(needleAng) * (r - dp(6))); + float ny = cy - (float) (Math.cos(needleAng) * (r - dp(6))); + needle.reset(); + needle.moveTo(cx, cy); + needle.lineTo(nx, ny); + needle.lineTo(cx + dp(4), cy); + needle.close(); + canvas.drawPath(needle, needlePaint); + canvas.drawCircle(cx, cy, dp(3), needlePaint); + } + + private static float normalize(float deg) { + float d = deg % 360f; + return d < 0 ? d + 360f : d; + } + + private float dp(float v) { + return v * getResources().getDisplayMetrics().density; + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/view/PlotterSpeedometerView.java b/app/src/main/java/com/grigowashere/aismap/view/PlotterSpeedometerView.java new file mode 100644 index 0000000..fd1cf8a --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/view/PlotterSpeedometerView.java @@ -0,0 +1,141 @@ +package com.grigowashere.aismap.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.grigowashere.aismap.R; + +import java.util.Locale; + +/** + * Простой аналоговый спидометр (узлы) в стиле картплоттера. + */ +public class PlotterSpeedometerView extends View { + + private static final float MAX_SPEED_KNOTS = 30f; + + private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint arcPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF arcRect = new RectF(); + + private float speedKnots = 0f; + private String title; + + public PlotterSpeedometerView(Context context) { + super(context); + init(); + } + + public PlotterSpeedometerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + title = getContext().getString(R.string.radar_plotter_sog_label); + int labelColor = ContextCompat.getColor(getContext(), R.color.plotter_text_label); + int textColor = ContextCompat.getColor(getContext(), R.color.plotter_text_primary); + int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent); + + bgPaint.setStyle(Paint.Style.STROKE); + bgPaint.setStrokeWidth(dp(2)); + bgPaint.setColor(0x44FFFFFF); + + arcPaint.setStyle(Paint.Style.STROKE); + arcPaint.setStrokeWidth(dp(6)); + arcPaint.setColor(accent); + arcPaint.setStrokeCap(Paint.Cap.ROUND); + + tickPaint.setStyle(Paint.Style.STROKE); + tickPaint.setStrokeWidth(dp(1)); + tickPaint.setColor(labelColor); + + needlePaint.setStyle(Paint.Style.STROKE); + needlePaint.setStrokeWidth(dp(2.5f)); + needlePaint.setColor(accent); + needlePaint.setStrokeCap(Paint.Cap.ROUND); + + labelPaint.setColor(labelColor); + labelPaint.setTextSize(dp(10)); + labelPaint.setLetterSpacing(0.08f); + + valuePaint.setColor(textColor); + valuePaint.setTextSize(dp(18)); + valuePaint.setFakeBoldText(true); + } + + public void setSpeedKnots(double knots) { + float v = (float) Math.max(0.0, Math.min(MAX_SPEED_KNOTS, knots)); + if (Math.abs(v - speedKnots) > 0.05f) { + speedKnots = v; + invalidate(); + } + } + + @Override + protected void onDraw(Canvas canvas) { + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) return; + + float pad = dp(8); + float titleY = dp(14); + canvas.drawText(title, w * 0.5f - labelPaint.measureText(title) / 2f, titleY, labelPaint); + + float cx = w * 0.5f; + float bottom = h - pad; + float maxRadius = Math.min((w - 2f * pad) * 0.5f, bottom - titleY - pad); + float radius = Math.max(dp(12), maxRadius * 0.88f); + float cy = bottom; + arcRect.set(cx - radius, cy - radius, cx + radius, cy + radius); + + canvas.drawArc(arcRect, 180f, 180f, false, bgPaint); + + float sweep = 180f * (speedKnots / MAX_SPEED_KNOTS); + canvas.drawArc(arcRect, 180f, sweep, false, arcPaint); + + for (int k = 0; k <= 30; k += 5) { + float frac = k / MAX_SPEED_KNOTS; + double ang = Math.toRadians(180 + 180 * frac); + float x1 = cx + (float) (Math.cos(ang) * (radius - dp(4))); + float y1 = cy + (float) (Math.sin(ang) * (radius - dp(4))); + float x2 = cx + (float) (Math.cos(ang) * radius); + float y2 = cy + (float) (Math.sin(ang) * radius); + canvas.drawLine(x1, y1, x2, y2, tickPaint); + if (k % 10 == 0) { + String t = String.valueOf(k); + float tw = labelPaint.measureText(t); + canvas.drawText(t, + cx + (float) (Math.cos(ang) * (radius - dp(16))) - tw / 2f, + cy + (float) (Math.sin(ang) * (radius - dp(16))) + dp(4), + labelPaint); + } + } + + double needleAng = Math.toRadians(180 + 180 * (speedKnots / MAX_SPEED_KNOTS)); + float nx = cx + (float) (Math.cos(needleAng) * (radius - dp(10))); + float ny = cy + (float) (Math.sin(needleAng) * (radius - dp(10))); + canvas.drawLine(cx, cy, nx, ny, needlePaint); + canvas.drawCircle(cx, cy, dp(4), needlePaint); + + String val = String.format(Locale.US, "%.1f", speedKnots); + canvas.drawText(val, cx - valuePaint.measureText(val) / 2f, cy - dp(6), valuePaint); + String unit = "kn"; + canvas.drawText(unit, cx - labelPaint.measureText(unit) / 2f, cy + dp(12), labelPaint); + } + + private float dp(float v) { + return v * getResources().getDisplayMetrics().density; + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java b/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java new file mode 100644 index 0000000..f920c2d --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java @@ -0,0 +1,189 @@ +package com.grigowashere.aismap.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.grigowashere.aismap.R; +import com.grigowashere.aismap.controllers.AppCoordinator; +import com.grigowashere.aismap.utils.SettingsManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * Таблица ближайших AIS-целей для режима картплоттера. + */ +public class PlotterTargetsTableView extends View { + + public static final class Row { + public final String name; + public final double bearingDeg; + public final double distanceMeters; + + public Row(String name, double bearingDeg, double distanceMeters) { + this.name = name; + this.bearingDeg = bearingDeg; + this.distanceMeters = distanceMeters; + } + } + + private static final int MAX_ROWS = 8; + + private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint headerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint rowPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint accentPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint emptyPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint dividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final List rows = Collections.synchronizedList(new ArrayList<>()); + private SettingsManager settingsManager; + private String title; + private String emptyText; + + public PlotterTargetsTableView(Context context) { + super(context); + init(); + } + + public PlotterTargetsTableView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + settingsManager = new SettingsManager(getContext()); + title = getContext().getString(R.string.radar_plotter_table_title); + emptyText = getContext().getString(R.string.radar_plotter_table_empty); + + int label = ContextCompat.getColor(getContext(), R.color.plotter_text_label); + int text = ContextCompat.getColor(getContext(), R.color.plotter_text_primary); + int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent); + + titlePaint.setColor(accent); + titlePaint.setTextSize(dp(10)); + titlePaint.setTypeface(Typeface.DEFAULT_BOLD); + titlePaint.setLetterSpacing(0.06f); + + headerPaint.setColor(label); + headerPaint.setTextSize(dp(9)); + + rowPaint.setColor(text); + rowPaint.setTextSize(dp(10)); + rowPaint.setTypeface(Typeface.DEFAULT_BOLD); + + accentPaint.set(rowPaint); + accentPaint.setColor(accent); + + emptyPaint.setColor(label); + emptyPaint.setTextSize(dp(10)); + + dividerPaint.setColor(0x33FFFFFF); + dividerPaint.setStrokeWidth(dp(1)); + } + + public void setRowsFromCoordinatorEntries(List entries) { + List next = new ArrayList<>(); + if (entries != null) { + for (AppCoordinator.DangerEntry e : entries) { + if (e == null || e.vessel == null) continue; + String label = e.vessel.getVesselName(); + if (label == null || label.trim().isEmpty()) { + label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—"; + } + next.add(new Row(label, e.bearingDegrees, e.distanceMeters)); + if (next.size() >= MAX_ROWS) break; + } + } + synchronized (rows) { + rows.clear(); + rows.addAll(next); + } + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) return; + + float pad = dp(8); + float y = pad + dp(12); + canvas.drawText(title, pad, y, titlePaint); + y += dp(14); + + boolean useNm = settingsManager != null + && SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit()); + String cpaNa = getContext().getString(R.string.radar_plotter_cpa_na); + + float colName = pad; + float colBrg = w * 0.52f; + float colRng = w * 0.68f; + float colCpa = w * 0.84f; + + canvas.drawText(getContext().getString(R.string.radar_plotter_col_name), colName, y, headerPaint); + canvas.drawText(getContext().getString(R.string.radar_plotter_col_brg), colBrg, y, headerPaint); + canvas.drawText(getContext().getString(R.string.radar_plotter_col_rng), colRng, y, headerPaint); + canvas.drawText(getContext().getString(R.string.radar_plotter_col_cpa), colCpa, y, headerPaint); + y += dp(6); + canvas.drawLine(pad, y, w - pad, y, dividerPaint); + y += dp(10); + + List snapshot; + synchronized (rows) { + snapshot = new ArrayList<>(rows); + } + + if (snapshot.isEmpty()) { + canvas.drawText(emptyText, pad, y, emptyPaint); + return; + } + + float rowH = dp(14); + float nameMax = colBrg - colName - dp(4); + for (Row r : snapshot) { + String name = ellipsize(r.name, rowPaint, nameMax); + canvas.drawText(name, colName, y, rowPaint); + canvas.drawText(String.format(Locale.US, "%03.0f\u00B0", r.bearingDeg), colBrg, y, rowPaint); + canvas.drawText(formatDistance(r.distanceMeters, useNm), colRng, y, rowPaint); + canvas.drawText(cpaNa, colCpa, y, accentPaint); + y += rowH; + if (y > h - pad) break; + } + } + + private static String formatDistance(double meters, boolean useNm) { + if (useNm) { + return String.format(Locale.US, "%.2f", meters / 1852.0); + } + if (meters >= 1000.0) { + return String.format(Locale.US, "%.1f", meters / 1000.0); + } + return String.format(Locale.US, "%.0f", meters); + } + + private static String ellipsize(String text, Paint paint, float maxWidth) { + if (text == null) return ""; + if (maxWidth <= 0 || paint.measureText(text) <= maxWidth) return text; + String ellipsis = "\u2026"; + int len = text.length(); + while (len > 0 && paint.measureText(text.substring(0, len) + ellipsis) > maxWidth) { + len--; + } + return len <= 0 ? ellipsis : text.substring(0, len) + ellipsis; + } + + private float dp(float v) { + return v * getResources().getDisplayMetrics().density; + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/view/RadarGraticuleOverlay.java b/app/src/main/java/com/grigowashere/aismap/view/RadarGraticuleOverlay.java new file mode 100644 index 0000000..f1886dd --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/view/RadarGraticuleOverlay.java @@ -0,0 +1,319 @@ +package com.grigowashere.aismap.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.Choreographer; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.grigowashere.aismap.R; +import com.grigowashere.aismap.controllers.AppCoordinator; +import com.grigowashere.aismap.utils.RangeMath; +import com.grigowashere.aismap.utils.SettingsManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * PPI-наложение: кольца дальности, сетка пеленгов, «свип» и цели AIS. + */ +public class RadarGraticuleOverlay extends View { + + public static final class Blip { + public final double bearingDeg; + /** 0..1 относительно радиуса PPI */ + public final float rangeFraction; + public final boolean danger; + + public Blip(double bearingDeg, float rangeFraction, boolean danger) { + this.bearingDeg = bearingDeg; + this.rangeFraction = rangeFraction; + this.danger = danger; + } + } + + private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint gridBrightPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint sweepPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint sweepGlowPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint sweepCorePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint blipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint dangerBlipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint vignettePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path sweepPath = new Path(); + private final RectF circleRect = new RectF(); + + private final List blips = new ArrayList<>(); + private final Choreographer choreographer = Choreographer.getInstance(); + private final Choreographer.FrameCallback sweepFrameCallback = this::onSweepFrame; + + private float sweepAngle = 0f; + private long lastSweepNanos = 0L; + private boolean sweepRunning; + private double rangeMeters = 1852.0 * 5.0; + private String rangeUnit = SettingsManager.RANGE_UNIT_NM; + private float headingUpDeg = 0f; + + /** Полный оборот свипа, секунды */ + private static final float SWEEP_PERIOD_SEC = 5f; + + public RadarGraticuleOverlay(Context context) { + super(context); + init(); + } + + public RadarGraticuleOverlay(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + setLayerType(LAYER_TYPE_HARDWARE, null); + + int grid = ContextCompat.getColor(getContext(), R.color.plotter_radar_grid); + int gridBright = ContextCompat.getColor(getContext(), R.color.plotter_radar_grid_bright); + int sweep = ContextCompat.getColor(getContext(), R.color.plotter_radar_sweep); + int blip = ContextCompat.getColor(getContext(), R.color.plotter_target_blip); + + gridPaint.setStyle(Paint.Style.STROKE); + gridPaint.setStrokeWidth(dp(1)); + gridPaint.setColor(grid); + + gridBrightPaint.set(gridPaint); + gridBrightPaint.setColor(gridBright); + gridBrightPaint.setStrokeWidth(dp(1.2f)); + + sweepPaint.setStyle(Paint.Style.FILL); + sweepPaint.setColor(sweep); + sweepPaint.setAlpha(64); + + sweepGlowPaint.set(sweepPaint); + sweepGlowPaint.setAlpha(36); + + sweepCorePaint.setStyle(Paint.Style.STROKE); + sweepCorePaint.setColor(sweep); + sweepCorePaint.setStrokeWidth(dp(2.5f)); + sweepCorePaint.setStrokeCap(Paint.Cap.ROUND); + sweepCorePaint.setAlpha(220); + + blipPaint.setStyle(Paint.Style.FILL); + blipPaint.setColor(blip); + + dangerBlipPaint.set(blipPaint); + dangerBlipPaint.setColor(ContextCompat.getColor(getContext(), R.color.plotter_text_accent)); + + labelPaint.setColor(ContextCompat.getColor(getContext(), R.color.plotter_text_label)); + labelPaint.setTextSize(dp(9)); + labelPaint.setLetterSpacing(0.05f); + + vignettePaint.setStyle(Paint.Style.FILL); + vignettePaint.setColor(0x44000000); + } + + public void setRangeMeters(double rangeMeters) { + if (rangeMeters > 0) { + this.rangeMeters = rangeMeters; + invalidate(); + } + } + + public void setRangeUnit(String unit) { + if (unit != null && !unit.isEmpty()) { + rangeUnit = unit; + invalidate(); + } + } + + public void setHeadingUpDeg(float headingUpDeg) { + this.headingUpDeg = headingUpDeg; + } + + public void setBlipsFromDangerEntries(List entries, + double dangerRadiusMeters) { + List next = new ArrayList<>(); + if (entries != null && rangeMeters > 0) { + for (AppCoordinator.DangerEntry e : entries) { + if (e == null) continue; + float frac = (float) Math.min(1.0, e.distanceMeters / rangeMeters); + boolean danger = dangerRadiusMeters > 0 && e.distanceMeters <= dangerRadiusMeters; + next.add(new Blip(e.bearingDegrees, frac, danger)); + } + } + synchronized (blips) { + blips.clear(); + blips.addAll(next); + } + invalidate(); + } + + public void setAllTargetsInRange(List entries, + double dangerRadiusMeters) { + List next = new ArrayList<>(); + if (entries != null && rangeMeters > 0) { + for (AppCoordinator.DangerEntry e : entries) { + if (e == null) continue; + float frac = (float) Math.min(1.0, e.distanceMeters / rangeMeters); + boolean danger = dangerRadiusMeters > 0 && e.distanceMeters <= dangerRadiusMeters; + next.add(new Blip(e.bearingDegrees, frac, danger)); + } + } + synchronized (blips) { + blips.clear(); + blips.addAll(next); + } + invalidate(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + startSweepAnimation(); + } + + @Override + protected void onDetachedFromWindow() { + stopSweepAnimation(); + super.onDetachedFromWindow(); + } + + private void startSweepAnimation() { + if (sweepRunning) return; + sweepRunning = true; + lastSweepNanos = System.nanoTime(); + choreographer.postFrameCallback(sweepFrameCallback); + } + + private void stopSweepAnimation() { + if (!sweepRunning) return; + sweepRunning = false; + choreographer.removeFrameCallback(sweepFrameCallback); + } + + private void onSweepFrame(long frameTimeNanos) { + if (!sweepRunning) return; + if (lastSweepNanos > 0L) { + float dtSec = (frameTimeNanos - lastSweepNanos) / 1_000_000_000f; + sweepAngle = (sweepAngle + (360f / SWEEP_PERIOD_SEC) * dtSec) % 360f; + invalidate(); + } + lastSweepNanos = frameTimeNanos; + choreographer.postFrameCallback(sweepFrameCallback); + } + + @Override + protected void onDraw(Canvas canvas) { + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) return; + + float cx = w * 0.5f; + float cy = h * 0.5f; + float radius = Math.min(cx, cy) - dp(4); + circleRect.set(cx - radius, cy - radius, cx + radius, cy + radius); + + canvas.saveLayer(0, 0, w, h, null); + + // Затемнение за пределами круга (маска PPI) + canvas.drawRect(0, 0, w, h, vignettePaint); + Paint clear = new Paint(); + clear.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + canvas.drawCircle(cx, cy, radius, clear); + clear.setXfermode(null); + + canvas.save(); + canvas.clipRect(circleRect); + + // Кольца PPI (4 кольца) + метки дальности на пересечениях с осями N/E/S/W + for (int i = 1; i <= 4; i++) { + float r = radius * i / 4f; + Paint p = (i == 4) ? gridBrightPaint : gridPaint; + canvas.drawCircle(cx, cy, r, p); + drawRingRangeLabels(canvas, cx, cy, r, rangeMeters * i / 4.0); + } + + // Лучи каждые 30° + for (int deg = 0; deg < 360; deg += 30) { + double rad = Math.toRadians(deg - headingUpDeg); + float x2 = cx + (float) (Math.sin(rad) * radius); + float y2 = cy - (float) (Math.cos(rad) * radius); + canvas.drawLine(cx, cy, x2, y2, deg % 90 == 0 ? gridBrightPaint : gridPaint); + } + + // Свип с мягким свечением + double sweepRad = Math.toRadians(sweepAngle - headingUpDeg); + float tipX = cx + (float) (Math.sin(sweepRad) * radius); + float tipY = cy - (float) (Math.cos(sweepRad) * radius); + drawSweepWedge(canvas, cx, cy, radius, sweepRad, 0.22, sweepGlowPaint); + drawSweepWedge(canvas, cx, cy, radius, sweepRad, 0.10, sweepPaint); + canvas.drawLine(cx, cy, tipX, tipY, sweepCorePaint); + + List snapshot; + synchronized (blips) { + snapshot = new ArrayList<>(blips); + } + for (Blip b : snapshot) { + double rel = Math.toRadians(b.bearingDeg - headingUpDeg); + float dist = radius * b.rangeFraction; + float bx = cx + (float) (Math.sin(rel) * dist); + float by = cy - (float) (Math.cos(rel) * dist); + Paint p = b.danger ? dangerBlipPaint : blipPaint; + canvas.drawRect(bx - dp(3), by - dp(3), bx + dp(3), by + dp(3), p); + } + + canvas.restore(); + canvas.restore(); + } + + private void drawSweepWedge(Canvas canvas, float cx, float cy, float radius, + double sweepRad, double halfAngleRad, Paint paint) { + sweepPath.reset(); + sweepPath.moveTo(cx, cy); + sweepPath.lineTo(cx + (float) (Math.sin(sweepRad) * radius), + cy - (float) (Math.cos(sweepRad) * radius)); + sweepPath.lineTo(cx + (float) (Math.sin(sweepRad + halfAngleRad) * radius * 0.12f), + cy - (float) (Math.cos(sweepRad + halfAngleRad) * radius * 0.12f)); + sweepPath.close(); + canvas.drawPath(sweepPath, paint); + } + + /** Метки дальности кольца на пересечениях с пеленгами 0°/90°/180°/270° (курс вверх). */ + private void drawRingRangeLabels(Canvas canvas, float cx, float cy, float ringRadius, + double ringMeters) { + String label = formatRangeLabel(ringMeters); + float tw = labelPaint.measureText(label); + float th = labelPaint.getTextSize(); + float pad = dp(3); + float northY = cy - ringRadius - pad; + canvas.drawText(label, cx - tw / 2f, northY, labelPaint); + float southY = cy + ringRadius + th + pad; + canvas.drawText(label, cx - tw / 2f, southY, labelPaint); + float eastX = cx + ringRadius + pad; + canvas.drawText(label, eastX, cy + th / 3f, labelPaint); + float westX = cx - ringRadius - pad - tw; + canvas.drawText(label, westX, cy + th / 3f, labelPaint); + } + + private String formatRangeLabel(double meters) { + if (SettingsManager.RANGE_UNIT_KM.equals(rangeUnit)) { + if (meters >= RangeMath.METERS_PER_KM) { + return String.format(Locale.US, "%.1f km", meters / RangeMath.METERS_PER_KM); + } + return String.format(Locale.US, "%.0f m", meters); + } + return String.format(Locale.US, "%.1f nm", meters / RangeMath.METERS_PER_NM); + } + + private float dp(float v) { + return v * getResources().getDisplayMetrics().density; + } +} diff --git a/app/src/main/res/drawable/ic_radar_plotter.xml b/app/src/main/res/drawable/ic_radar_plotter.xml new file mode 100644 index 0000000..bf250e2 --- /dev/null +++ b/app/src/main/res/drawable/ic_radar_plotter.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_signal_off.xml b/app/src/main/res/drawable/ic_signal_off.xml new file mode 100644 index 0000000..dec3d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_signal_off.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/plotter_bezel_background.xml b/app/src/main/res/drawable/plotter_bezel_background.xml new file mode 100644 index 0000000..fe078af --- /dev/null +++ b/app/src/main/res/drawable/plotter_bezel_background.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/drawable/plotter_panel_background.xml b/app/src/main/res/drawable/plotter_panel_background.xml new file mode 100644 index 0000000..90d0a22 --- /dev/null +++ b/app/src/main/res/drawable/plotter_panel_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/plotter_radar_viewport_bg.xml b/app/src/main/res/drawable/plotter_radar_viewport_bg.xml new file mode 100644 index 0000000..a83f0de --- /dev/null +++ b/app/src/main/res/drawable/plotter_radar_viewport_bg.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout-port/activity_radar_plotter.xml b/app/src/main/res/layout-port/activity_radar_plotter.xml new file mode 100644 index 0000000..0ee7d60 --- /dev/null +++ b/app/src/main/res/layout-port/activity_radar_plotter.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_interfaces_settings.xml b/app/src/main/res/layout/activity_interfaces_settings.xml index 92c0a17..dd80fff 100644 --- a/app/src/main/res/layout/activity_interfaces_settings.xml +++ b/app/src/main/res/layout/activity_interfaces_settings.xml @@ -1,8 +1,10 @@ + android:text="@string/interfaces_title" + android:textAppearance="?attr/textAppearanceHeadlineSmall" + android:textColor="?attr/colorOnSurface" /> + app:cardCornerRadius="12dp"> + android:layout_marginBottom="12dp" + android:text="@string/interfaces_section_udp" + android:textAppearance="?attr/textAppearanceTitleMedium" + android:textColor="?attr/colorOnSurface" /> + android:hint="@string/interfaces_udp_port_hint" + app:helperText="@string/interfaces_udp_port_helper"> + android:checked="true" + android:text="@string/interfaces_udp_enabled" /> + + app:cardCornerRadius="12dp"> + android:layout_marginBottom="12dp" + android:text="@string/interfaces_section_ble" + android:textAppearance="?attr/textAppearanceTitleMedium" + android:textColor="?attr/colorOnSurface" /> + android:layout_marginBottom="8dp" + android:text="@string/interfaces_ble_enabled" /> + android:hint="@string/interfaces_ble_mac_hint" + app:helperText="@string/interfaces_ble_mac_helper"> + android:inputType="text" /> @@ -122,20 +119,20 @@ android:layout_height="wrap_content" android:orientation="horizontal"> -