diff --git a/.gitignore b/.gitignore
index aa724b7..889f3aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
+/.idea/vcs.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 145b29e..816f8b8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -43,6 +43,12 @@
+
+
centerOnVessel());
- btnTestCompass.setOnClickListener(v -> testCompass());
+ btnMapOrientation.setOnClickListener(v -> toggleMapOrientation());
+ btnSettings.setOnClickListener(v -> showSettings());
// Кнопка для показа информации о судне
// Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info);
@@ -114,6 +130,18 @@ public class MainActivity extends AppCompatActivity {
// Настраиваем слушатель изменения размера док-виджета
compassView.setOnDockResizeListener(newHeight -> {
Log.d(TAG, "Compass dock height changed to: " + newHeight);
+ // Обновляем позицию панели управления при любом изменении размера docked виджета
+ updateControlPanelPosition();
+ });
+
+ // Настраиваем слушатель изменения состояния docked
+ compassView.setOnDockStateChangeListener((isDocked, isTop) -> {
+ Log.d(TAG, "Compass dock state changed: docked=" + isDocked + ", top=" + isTop);
+
+ // Перепозиционируем все docked виджеты
+ BaseDockWidget.repositionAllDockedWidgets((ViewGroup) compassView.getParent());
+
+ updateControlPanelPosition();
});
//smt changed
// Настраиваем магнитный компас
@@ -144,6 +172,48 @@ public class MainActivity extends AppCompatActivity {
// Принудительная отрисовка
compassView.invalidate();
+
+ // Инициализируем начальную позицию панели управления
+ compassView.post(() -> {
+ updateControlPanelPosition();
+ });
+ }
+
+ private void setupCoordinatesWidget() {
+ // Настраиваем слушатель изменения размера dock-виджета
+ coordinatesWidget.setOnDockResizeListener(newHeight -> {
+ Log.d(TAG, "Coordinates dock height changed to: " + newHeight);
+ // Обновляем позицию панели управления при любом изменении размера docked виджета
+ updateControlPanelPosition();
+ });
+
+ // Настраиваем слушатель изменения состояния docked
+ coordinatesWidget.setOnDockStateChangeListener((isDocked, isTop) -> {
+ Log.d(TAG, "Coordinates dock state changed: docked=" + isDocked + ", top=" + isTop);
+
+ // Перепозиционируем все docked виджеты
+ BaseDockWidget.repositionAllDockedWidgets((ViewGroup) coordinatesWidget.getParent());
+
+ updateControlPanelPosition();
+ });
+
+ // Устанавливаем виджет координат в dock-режим внизу экрана
+ coordinatesWidget.post(() -> {
+ Log.d(TAG, "Setting coordinates widget to dock mode");
+ coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
+ coordinatesWidget.invalidate(); // Принудительная отрисовка
+
+ // Принудительно обновляем виджет с тестовыми данными
+ Vessel testVessel = new Vessel();
+ testVessel.setLatitude(55.7558);
+ testVessel.setLongitude(37.6176);
+ testVessel.setSpeed(5.5);
+ testVessel.setCourse(45.0);
+ testVessel.setAccuracy(3.0f);
+ coordinatesWidget.updateVessel(testVessel);
+
+ updateControlPanelPosition();
+ });
}
private void onUpdateCompass(float azimuth, List nearbyVessels) {
@@ -248,6 +318,9 @@ public class MainActivity extends AppCompatActivity {
}
private void initializeControllers() {
+ // Инициализация менеджера настроек
+ settingsManager = new SettingsManager(this);
+
// Инициализация главного контроллера
appController = new AppController(this);
@@ -292,10 +365,8 @@ public class MainActivity extends AppCompatActivity {
}
private void startControllers() {
- // Включаем GPS и UDP по умолчанию
- appController.setGPSLocationEnabled(true);
- appController.setAndroidNMEAEnabled(true);
- appController.setUDPEnabled(true);
+ // Загружаем настройки и применяем их
+ applySettings();
// Запускаем все слушатели
appController.startAllListeners();
@@ -331,6 +402,11 @@ public class MainActivity extends AppCompatActivity {
// tvStatus.setText("Статус: GPS активен, данные получены");
// }
+ // Обновляем виджет координат
+ if (coordinatesWidget != null) {
+ coordinatesWidget.updateVessel(vessel);
+ }
+
// Обновляем BottomSheet, если он открыт
if (ownVesselBottomSheet != null && ownVesselBottomSheet.isShowing()) {
updateBottomSheetUI();
@@ -387,43 +463,94 @@ public class MainActivity extends AppCompatActivity {
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
}
- private void testCompass() {
- if (compassView != null) {
- // Создаем тестовые AIS суда
- List testVessels = new ArrayList<>();
-
- AISVessel testVessel1 = new AISVessel("123456789");
- testVessel1.setLatitude(59.9343);
- testVessel1.setLongitude(30.3351);
- testVessel1.setCourse(45);
- testVessel1.setSpeed(10);
- testVessel1.setNavigationalStatus("under way using engine");
- testVessels.add(testVessel1);
-
- AISVessel testVessel2 = new AISVessel("987654321");
- testVessel2.setLatitude(59.9343);
- testVessel2.setLongitude(30.3351);
- testVessel2.setCourse(180);
- testVessel2.setSpeed(5);
- testVessel2.setNavigationalStatus("at anchor");
- testVessels.add(testVessel2);
-
- compassView.updateNearbyVessels(testVessels);
-
- // Проверяем доступность магнитного компаса
- if (compassSensor.isAvailable()) {
- Toast.makeText(this, "Магнитный компас доступен и работает", Toast.LENGTH_SHORT).show();
- } else {
- Toast.makeText(this, "Магнитный компас недоступен", Toast.LENGTH_SHORT).show();
- }
+ private void toggleMapOrientation() {
+ // TODO: Реализовать переключение ориентации карты
+ // Состояния: север, курс, компас
+ Toast.makeText(this, "Переключение ориентации карты (в разработке)", Toast.LENGTH_SHORT).show();
+ }
+
+ private void showSettings() {
+ Intent intent = new Intent(this, SettingsActivity.class);
+ startActivityForResult(intent, SETTINGS_REQUEST_CODE);
+ }
+
+ /**
+ * Обновляет позицию панели управления в зависимости от состояния docked виджетов
+ */
+ private void updateControlPanelPosition() {
+ if (controlPanel != null) {
+ runOnUiThread(() -> {
+ // Получаем текущие параметры layout
+ android.widget.RelativeLayout.LayoutParams params =
+ (android.widget.RelativeLayout.LayoutParams) controlPanel.getLayoutParams();
+
+ int topMargin = dpToPx(16); // По умолчанию отступ сверху
+ int bottomMargin = dpToPx(16); // По умолчанию отступ снизу
+
+ // Вычисляем общую высоту всех docked виджетов сверху
+ int totalTopHeight = 0;
+ if (compassView != null && compassView.isDocked() && compassView.isDockTop()) {
+ totalTopHeight += compassView.getHeight();
+ Log.d(TAG, "Compass docked top, height: " + compassView.getHeight());
+ }
+ if (coordinatesWidget != null && coordinatesWidget.isDocked() && coordinatesWidget.isDockTop()) {
+ totalTopHeight += coordinatesWidget.getHeight();
+ Log.d(TAG, "Coordinates docked top, height: " + coordinatesWidget.getHeight());
+ }
+
+ // Вычисляем общую высоту всех docked виджетов снизу
+ int totalBottomHeight = 0;
+ if (compassView != null && compassView.isDocked() && !compassView.isDockTop()) {
+ totalBottomHeight += compassView.getHeight();
+ Log.d(TAG, "Compass docked bottom, height: " + compassView.getHeight());
+ }
+ if (coordinatesWidget != null && coordinatesWidget.isDocked() && !coordinatesWidget.isDockTop()) {
+ totalBottomHeight += coordinatesWidget.getHeight();
+ Log.d(TAG, "Coordinates docked bottom, height: " + coordinatesWidget.getHeight());
+ }
+
+ // Устанавливаем отступы с учетом всех docked виджетов
+ if (totalTopHeight > 0) {
+ topMargin = totalTopHeight + dpToPx(8); // + небольшой отступ
+ }
+ if (totalBottomHeight > 0) {
+ bottomMargin = totalBottomHeight + dpToPx(8); // + небольшой отступ
+ }
+
+ // Устанавливаем отступы
+ params.topMargin = topMargin;
+ params.bottomMargin = bottomMargin;
+
+ // Применяем новые параметры
+ controlPanel.setLayoutParams(params);
+
+ Log.d(TAG, "Control panel position updated: " +
+ "topMargin=" + topMargin + "px, " +
+ "bottomMargin=" + bottomMargin + "px, " +
+ "totalTopHeight=" + totalTopHeight + "px, " +
+ "totalBottomHeight=" + totalBottomHeight + "px, " +
+ "compassDocked=" + (compassView != null ? compassView.isDocked() : false) +
+ ", compassTop=" + (compassView != null ? compassView.isDockTop() : false) +
+ ", coordinatesDocked=" + (coordinatesWidget != null ? coordinatesWidget.isDocked() : false) +
+ ", coordinatesTop=" + (coordinatesWidget != null ? coordinatesWidget.isDockTop() : false));
+ });
}
}
+
+
private void clearAIS() {
appController.clearAISVessels();
Toast.makeText(this, "AIS суда очищены", Toast.LENGTH_SHORT).show();
}
+ /**
+ * Конвертирует dp в px
+ */
+ private int dpToPx(int dp) {
+ return (int) (dp * getResources().getDisplayMetrics().density);
+ }
+
@Override
protected void onStart() {
super.onStart();
@@ -548,6 +675,32 @@ public class MainActivity extends AppCompatActivity {
}
}
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == SETTINGS_REQUEST_CODE) {
+ if (resultCode == RESULT_OK && data != null) {
+ boolean settingsChanged = data.getBooleanExtra("settings_changed", false);
+ boolean needsRestart = data.getBooleanExtra("needs_restart", false);
+
+ if (settingsChanged) {
+ Log.i(TAG, "Настройки изменены, применяем изменения");
+
+ if (needsRestart) {
+ Log.i(TAG, "Требуется перезапуск сервисов");
+ restartServices();
+ } else {
+ Log.i(TAG, "Применяем настройки без перезапуска");
+ applySettings();
+ }
+
+ Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+ }
+
// Меню
@Override
@@ -1082,4 +1235,75 @@ public class MainActivity extends AppCompatActivity {
}
}
}
+
+ /**
+ * Применяет настройки к контроллерам
+ */
+ private void applySettings() {
+ if (settingsManager == null || appController == null) {
+ Log.w(TAG, "SettingsManager или AppController не инициализированы");
+ return;
+ }
+
+ try {
+ // Применяем UDP настройки
+ int udpPort = settingsManager.getUDPPort();
+ boolean udpEnabled = settingsManager.isUDPEnabled();
+
+ appController.setUDPPort(udpPort);
+ appController.setUDPEnabled(udpEnabled);
+
+ // Применяем NMEA настройки
+ boolean androidNMEAEnabled = settingsManager.isAndroidNMEAEnabled();
+ boolean udpNMEAEnabled = settingsManager.isUDPNMEAEnabled();
+
+ appController.setAndroidNMEAEnabled(androidNMEAEnabled);
+ appController.setUDPNMEAEnabled(udpNMEAEnabled);
+
+ // Применяем режим данных
+ String dataMode = settingsManager.getDataMode();
+ appController.setDataMode(dataMode);
+
+ Log.i(TAG, "Настройки применены: " + settingsManager.getSettingsSummary());
+
+ } catch (Exception e) {
+ Log.e(TAG, "Ошибка при применении настроек: " + e.getMessage(), e);
+ Toast.makeText(this, "Ошибка при применении настроек", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Перезапускает сервисы с новыми настройками
+ */
+ private void restartServices() {
+ if (appController == null) {
+ Log.w(TAG, "AppController не инициализирован");
+ return;
+ }
+
+ try {
+ Log.i(TAG, "Перезапускаем сервисы...");
+
+ // Останавливаем все слушатели
+ appController.stopAllListeners();
+
+ // Применяем новые настройки
+ applySettings();
+
+ // Перезапускаем UDP слушатель с новым портом, если нужно
+ if (settingsManager.shouldRestartUDP(appController.getUDPPort(), appController.isUDPEnabled())) {
+ appController.restartUDPListener();
+ }
+
+ // Запускаем слушатели с новыми настройками
+ appController.startAllListeners();
+
+ Log.i(TAG, "Сервисы успешно перезапущены");
+ Log.i(TAG, "Статус настроек: " + appController.getSettingsStatus());
+
+ } catch (Exception e) {
+ Log.e(TAG, "Ошибка при перезапуске сервисов: " + e.getMessage(), e);
+ Toast.makeText(this, "Ошибка при перезапуске сервисов", Toast.LENGTH_SHORT).show();
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java
new file mode 100644
index 0000000..fcccbdf
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java
@@ -0,0 +1,323 @@
+package com.grigowashere.aismap;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.Toast;
+
+import com.google.android.material.switchmaterial.SwitchMaterial;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.grigowashere.aismap.utils.SettingsManager;
+
+/**
+ * Экран настроек приложения
+ */
+public class SettingsActivity extends AppCompatActivity {
+
+ private static final String TAG = "SettingsActivity";
+
+ private SettingsManager settingsManager;
+
+ // UI элементы
+ private EditText etUDPPort;
+ private SwitchMaterial switchUDPEnabled;
+ private SwitchMaterial switchAndroidNMEAEnabled;
+ private SwitchMaterial switchUDPNMEAEnabled;
+ private RadioGroup radioGroupDataMode;
+ private RadioButton radioHybridMode;
+ private RadioButton radioNMEAOnly;
+ private RadioButton radioAndroidOnly;
+ private Button btnCancel;
+ private Button btnSave;
+
+ // Состояние настроек до изменений
+ private int originalUDPPort;
+ private boolean originalUDPEnabled;
+ private boolean originalAndroidNMEAEnabled;
+ private boolean originalUDPNMEAEnabled;
+ private String originalDataMode;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_settings);
+
+ // Инициализируем менеджер настроек
+ settingsManager = new SettingsManager(this);
+
+ // Инициализируем UI элементы
+ initializeViews();
+
+ // Загружаем текущие настройки
+ loadCurrentSettings();
+
+ // Сохраняем оригинальные значения
+ saveOriginalSettings();
+
+ // Настраиваем обработчики событий
+ setupEventHandlers();
+
+ Log.i(TAG, "SettingsActivity создан");
+ }
+
+ /**
+ * Инициализирует UI элементы
+ */
+ private void initializeViews() {
+ etUDPPort = findViewById(R.id.et_udp_port);
+ switchUDPEnabled = findViewById(R.id.switch_udp_enabled);
+ switchAndroidNMEAEnabled = findViewById(R.id.switch_android_nmea_enabled);
+ switchUDPNMEAEnabled = findViewById(R.id.switch_udp_nmea_enabled);
+ radioGroupDataMode = findViewById(R.id.radio_group_data_mode);
+ radioHybridMode = findViewById(R.id.radio_hybrid_mode);
+ radioNMEAOnly = findViewById(R.id.radio_nmea_only);
+ radioAndroidOnly = findViewById(R.id.radio_android_only);
+ btnCancel = findViewById(R.id.btn_cancel);
+ btnSave = findViewById(R.id.btn_save);
+ }
+
+ /**
+ * Загружает текущие настройки в UI
+ */
+ private void loadCurrentSettings() {
+ // UDP настройки
+ etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
+ switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
+
+ // NMEA настройки
+ switchAndroidNMEAEnabled.setChecked(settingsManager.isAndroidNMEAEnabled());
+ switchUDPNMEAEnabled.setChecked(settingsManager.isUDPNMEAEnabled());
+
+ // Режим данных
+ String dataMode = settingsManager.getDataMode();
+ switch (dataMode) {
+ case SettingsManager.DATA_MODE_HYBRID:
+ radioHybridMode.setChecked(true);
+ break;
+ case SettingsManager.DATA_MODE_NMEA_ONLY:
+ radioNMEAOnly.setChecked(true);
+ break;
+ case SettingsManager.DATA_MODE_ANDROID_ONLY:
+ radioAndroidOnly.setChecked(true);
+ break;
+ }
+
+ Log.i(TAG, "Настройки загружены в UI");
+ }
+
+ /**
+ * Сохраняет оригинальные значения настроек
+ */
+ private void saveOriginalSettings() {
+ originalUDPPort = settingsManager.getUDPPort();
+ originalUDPEnabled = settingsManager.isUDPEnabled();
+ originalAndroidNMEAEnabled = settingsManager.isAndroidNMEAEnabled();
+ originalUDPNMEAEnabled = settingsManager.isUDPNMEAEnabled();
+ originalDataMode = settingsManager.getDataMode();
+
+ Log.i(TAG, "Оригинальные настройки сохранены");
+ }
+
+ /**
+ * Настраивает обработчики событий
+ */
+ private void setupEventHandlers() {
+ // Кнопка отмены
+ btnCancel.setOnClickListener(v -> {
+ Log.i(TAG, "Нажата кнопка отмены");
+ finish();
+ });
+
+ // Кнопка сохранения
+ btnSave.setOnClickListener(v -> {
+ Log.i(TAG, "Нажата кнопка сохранения");
+ saveSettings();
+ });
+
+ // Обработчик изменения режима данных
+ radioGroupDataMode.setOnCheckedChangeListener((group, checkedId) -> {
+ updateDataModeDescription();
+ });
+
+ // Обработчики переключателей для валидации
+ switchAndroidNMEAEnabled.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ validateDataModeSettings();
+ });
+
+ switchUDPNMEAEnabled.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ validateDataModeSettings();
+ });
+ }
+
+ /**
+ * Обновляет описание режима данных
+ */
+ private void updateDataModeDescription() {
+ // Здесь можно добавить динамическое обновление описаний
+ // в зависимости от выбранного режима
+ }
+
+ /**
+ * Валидирует настройки режима данных
+ */
+ private void validateDataModeSettings() {
+ boolean androidNMEA = switchAndroidNMEAEnabled.isChecked();
+ boolean udpNMEA = switchUDPNMEAEnabled.isChecked();
+
+ // Если оба источника отключены, показываем предупреждение
+ if (!androidNMEA && !udpNMEA) {
+ Toast.makeText(this, "Внимание: Все источники данных отключены!",
+ Toast.LENGTH_LONG).show();
+ }
+
+ // Если выбран режим "только NMEA", но UDP NMEA отключен
+ if (radioNMEAOnly.isChecked() && !udpNMEA) {
+ Toast.makeText(this, "Для режима 'Только NMEA' необходимо включить UDP NMEA",
+ Toast.LENGTH_LONG).show();
+ }
+
+ // Если выбран режим "только Android", но Android NMEA отключен
+ if (radioAndroidOnly.isChecked() && !androidNMEA) {
+ Toast.makeText(this, "Для режима 'Только Android GPS' необходимо включить Android NMEA",
+ Toast.LENGTH_LONG).show();
+ }
+ }
+
+ /**
+ * Сохраняет настройки
+ */
+ private void saveSettings() {
+ try {
+ // Валидируем UDP порт
+ String portText = etUDPPort.getText().toString().trim();
+ if (portText.isEmpty()) {
+ Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ int udpPort;
+ try {
+ udpPort = Integer.parseInt(portText);
+ if (udpPort < 1 || udpPort > 65535) {
+ Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ } catch (NumberFormatException e) {
+ Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ // Получаем выбранный режим данных
+ String dataMode = getSelectedDataMode();
+
+ // Валидируем режим данных
+ if (!validateDataMode(dataMode)) {
+ return;
+ }
+
+ // Сохраняем настройки
+ settingsManager.setUDPPort(udpPort);
+ settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
+ settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked());
+ settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked());
+ settingsManager.setDataMode(dataMode);
+
+ Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary());
+
+ // Проверяем, нужно ли уведомить MainActivity об изменениях
+ boolean needsRestart = checkIfRestartNeeded();
+
+ // Возвращаем результат
+ Intent resultIntent = new Intent();
+ resultIntent.putExtra("settings_changed", true);
+ resultIntent.putExtra("needs_restart", needsRestart);
+ resultIntent.putExtra("udp_port", udpPort);
+ resultIntent.putExtra("udp_enabled", switchUDPEnabled.isChecked());
+ resultIntent.putExtra("android_nmea_enabled", switchAndroidNMEAEnabled.isChecked());
+ resultIntent.putExtra("udp_nmea_enabled", switchUDPNMEAEnabled.isChecked());
+ resultIntent.putExtra("data_mode", dataMode);
+
+ setResult(RESULT_OK, resultIntent);
+
+ Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
+ finish();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Ошибка при сохранении настроек: " + e.getMessage(), e);
+ Toast.makeText(this, "Ошибка при сохранении настроек", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Получает выбранный режим данных
+ */
+ private String getSelectedDataMode() {
+ int checkedId = radioGroupDataMode.getCheckedRadioButtonId();
+
+ if (checkedId == R.id.radio_hybrid_mode) {
+ return SettingsManager.DATA_MODE_HYBRID;
+ } else if (checkedId == R.id.radio_nmea_only) {
+ return SettingsManager.DATA_MODE_NMEA_ONLY;
+ } else if (checkedId == R.id.radio_android_only) {
+ return SettingsManager.DATA_MODE_ANDROID_ONLY;
+ } else {
+ return SettingsManager.DATA_MODE_HYBRID; // По умолчанию
+ }
+ }
+
+ /**
+ * Валидирует режим данных
+ */
+ private boolean validateDataMode(String dataMode) {
+ boolean androidNMEA = switchAndroidNMEAEnabled.isChecked();
+ boolean udpNMEA = switchUDPNMEAEnabled.isChecked();
+
+ switch (dataMode) {
+ case SettingsManager.DATA_MODE_HYBRID:
+ if (!androidNMEA && !udpNMEA) {
+ Toast.makeText(this, "Для гибридного режима необходимо включить хотя бы один источник данных",
+ Toast.LENGTH_LONG).show();
+ return false;
+ }
+ break;
+
+ case SettingsManager.DATA_MODE_NMEA_ONLY:
+ if (!udpNMEA) {
+ Toast.makeText(this, "Для режима 'Только NMEA' необходимо включить UDP NMEA",
+ Toast.LENGTH_LONG).show();
+ return false;
+ }
+ break;
+
+ case SettingsManager.DATA_MODE_ANDROID_ONLY:
+ if (!androidNMEA) {
+ Toast.makeText(this, "Для режима 'Только Android GPS' необходимо включить Android NMEA",
+ Toast.LENGTH_LONG).show();
+ return false;
+ }
+ break;
+ }
+
+ return true;
+ }
+
+ /**
+ * Проверяет, нужно ли перезапустить сервисы
+ */
+ private boolean checkIfRestartNeeded() {
+ return settingsManager.shouldRestartUDP(originalUDPPort, originalUDPEnabled) ||
+ settingsManager.shouldRestartNMEA(originalAndroidNMEAEnabled, originalUDPNMEAEnabled, originalDataMode);
+ }
+
+ @Override
+ public void onBackPressed() {
+ Log.i(TAG, "Нажата кнопка назад");
+ finish();
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java b/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java
index aaa5b74..c1debdc 100644
--- a/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java
+++ b/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java
@@ -37,7 +37,10 @@ public class AppController implements
private boolean isUDPEnabled;
private boolean isAndroidNMEAEnabled;
+ private boolean isUDPNMEAEnabled;
private boolean isGPSLocationEnabled;
+ private int udpPort;
+ private String dataMode;
// Callback для обновления UI
private UIUpdateCallback uiUpdateCallback;
@@ -82,7 +85,8 @@ public class AppController implements
nmeaParser.setHybridMode(true);
// Инициализация UDP слушателя (порт 10110 - стандартный для AIS)
- udpListener = new UDPListener(10110);
+ udpPort = 10110;
+ udpListener = new UDPListener(udpPort);
udpListener.setCallback(this);
// Инициализация Android NMEA слушателя (для курса, скорости, DOP)
@@ -131,29 +135,10 @@ public class AppController implements
});
}
- // Тестируем NMEA парсер (временно)
- testNMEAParser();
+
}
- /**
- * Тестирует NMEA парсер (временно для отладки)
- */
- private void testNMEAParser() {
- Log.i(TAG, "Тестируем NMEA парсер...");
-
- // Тестовые NMEA сообщения
- String[] testMessages = {
- "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47",
- "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A",
- "$GPVTG,054.7,T,034.4,M,005.5,N,010.2,K*48",
- "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.2,0.8,1.0*3E"
- };
-
- for (String message : testMessages) {
- Log.i(TAG, "Тестируем сообщение: " + message);
- nmeaParser.parseNMEA(message);
- }
- }
+
/**
* Останавливает все слушатели
@@ -221,12 +206,7 @@ public class AppController implements
}
}
- /**
- * Устанавливает UDP порт
- */
- public void setUDPPort(int port) {
- udpListener.setPort(port);
- }
+
/**
* Отправляет данные по UDP
@@ -328,7 +308,20 @@ public class AppController implements
@Override
public void onVesselUpdated(Vessel vessel) {
- // В гибридном режиме обновляем только дополнительные данные
+ Log.i(TAG, "🔄 onVesselUpdated вызван: lat=" + vessel.getLatitude() +
+ ", lon=" + vessel.getLongitude() +
+ ", course=" + vessel.getCourse() +
+ ", speed=" + vessel.getSpeed());
+
+ // Обновляем координаты, если они есть (для режима "только NMEA")
+ if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
+ ownVessel.setLatitude(vessel.getLatitude());
+ ownVessel.setLongitude(vessel.getLongitude());
+ Log.i(TAG, "📍 Координаты обновлены из NMEA: lat=" + vessel.getLatitude() +
+ ", lon=" + vessel.getLongitude());
+ }
+
+ // Обновляем дополнительные данные
if (vessel.getCourse() > 0) {
ownVessel.setCourse(vessel.getCourse());
updateCompass(); // Обновляем компас при изменении курса
@@ -347,6 +340,20 @@ public class AppController implements
", speed=" + vessel.getSpeed() +
", satellites=" + vessel.getSatellites());
+ // Обновляем карту в главном потоке
+ if (mapInterface != null) {
+ Log.i(TAG, "Обновляем позицию на карте из NMEA...");
+ new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
+ try {
+ Log.i(TAG, "Вызываем mapInterface.updateOwnVesselPosition из NMEA...");
+ mapInterface.updateOwnVesselPosition(ownVessel);
+ Log.i(TAG, "Позиция на карте обновлена из NMEA");
+ } catch (Exception e) {
+ Log.e(TAG, "Ошибка обновления позиции на карте из NMEA: " + e.getMessage(), e);
+ }
+ });
+ }
+
// Обновляем UI
if (uiUpdateCallback != null) {
uiUpdateCallback.onVesselPositionUpdated(ownVessel);
@@ -592,4 +599,111 @@ public class AppController implements
executor.shutdown();
}
}
+
+ // Методы для управления настройками
+
+ /**
+ * Устанавливает UDP порт
+ */
+ public void setUDPPort(int port) {
+ if (port < 1 || port > 65535) {
+ Log.w(TAG, "Некорректный UDP порт: " + port);
+ return;
+ }
+
+ this.udpPort = port;
+ Log.i(TAG, "UDP порт установлен: " + port);
+
+ // Если UDP слушатель уже создан, нужно будет его пересоздать
+ if (udpListener != null && udpListener.getPort() != port) {
+ Log.i(TAG, "UDP порт изменен, потребуется перезапуск UDP слушателя");
+ }
+ }
+
+ /**
+ * Получает текущий UDP порт
+ */
+ public int getUDPPort() {
+ return udpPort;
+ }
+
+ /**
+ * Включает/выключает UDP NMEA
+ */
+ public void setUDPNMEAEnabled(boolean enabled) {
+ this.isUDPNMEAEnabled = enabled;
+ Log.i(TAG, "UDP NMEA: " + (enabled ? "включен" : "выключен"));
+ }
+
+ /**
+ * Проверяет, включен ли UDP NMEA
+ */
+ public boolean isUDPNMEAEnabled() {
+ return isUDPNMEAEnabled;
+ }
+
+ /**
+ * Устанавливает режим работы с данными
+ */
+ public void setDataMode(String mode) {
+ this.dataMode = mode;
+ Log.i(TAG, "🔄 Режим данных установлен: " + mode);
+
+ // Применяем режим к NMEA парсеру
+ if (nmeaParser != null) {
+ boolean hybridMode = "hybrid".equals(mode);
+ nmeaParser.setHybridMode(hybridMode);
+ Log.i(TAG, "📍 Гибридный режим NMEA парсера: " + hybridMode);
+ Log.i(TAG, "📍 В режиме '" + mode + "' координаты будут " +
+ (hybridMode ? "браться из Android GPS API" : "браться из NMEA сообщений"));
+ }
+ }
+
+ /**
+ * Получает текущий режим работы с данными
+ */
+ public String getDataMode() {
+ return dataMode;
+ }
+
+ /**
+ * Перезапускает UDP слушатель с новым портом
+ */
+ public void restartUDPListener() {
+ if (udpListener != null) {
+ Log.i(TAG, "Перезапускаем UDP слушатель с портом: " + udpPort);
+
+ // Останавливаем текущий слушатель
+ udpListener.stop();
+ udpListener.cleanup();
+
+ // Создаем новый слушатель с новым портом
+ udpListener = new UDPListener(udpPort);
+ udpListener.setCallback(this);
+
+ // Запускаем, если UDP включен
+ if (isUDPEnabled) {
+ udpListener.start();
+ Log.i(TAG, "UDP слушатель перезапущен на порту: " + udpPort);
+ }
+ }
+ }
+
+ /**
+ * Получает статус всех настроек
+ */
+ public String getSettingsStatus() {
+ return String.format(
+ "UDP: порт=%d, включен=%s, NMEA=%s\n" +
+ "Android NMEA: %s\n" +
+ "GPS Location: %s\n" +
+ "Режим данных: %s",
+ udpPort,
+ isUDPEnabled ? "да" : "нет",
+ isUDPNMEAEnabled ? "включен" : "выключен",
+ isAndroidNMEAEnabled ? "включен" : "выключен",
+ isGPSLocationEnabled ? "включен" : "выключен",
+ dataMode != null ? dataMode : "не установлен"
+ );
+ }
}
diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/GPSHybridTest.java b/app/src/main/java/com/grigowashere/aismap/controllers/GPSHybridTest.java
deleted file mode 100644
index 1d1d3c6..0000000
--- a/app/src/main/java/com/grigowashere/aismap/controllers/GPSHybridTest.java
+++ /dev/null
@@ -1,184 +0,0 @@
-package com.grigowashere.aismap.controllers;
-
-import android.content.Context;
-import android.util.Log;
-import com.grigowashere.aismap.models.Vessel;
-
-/**
- * Тестовый класс для демонстрации работы гибридного GPS подхода
- * Показывает, как координаты получаются через Location API, а остальное через NMEA
- */
-public class GPSHybridTest {
-
- private static final String TAG = "GPSHybridTest";
-
- private Context context;
- private GPSLocationListener gpsLocationListener;
- private NMEAParser nmeaParser;
- private Vessel testVessel;
-
- public GPSHybridTest(Context context) {
- this.context = context;
- this.testVessel = new Vessel();
-
- // Инициализируем компоненты
- gpsLocationListener = new GPSLocationListener(context);
- nmeaParser = new NMEAParser();
-
- // Связываем их для гибридного режима
- nmeaParser.setGPSLocationListener(gpsLocationListener);
- nmeaParser.setHybridMode(true);
-
- // Устанавливаем callback'и
- gpsLocationListener.setCallback(new GPSLocationListener.LocationCallback() {
- @Override
- public void onLocationUpdated(Vessel vessel) {
- Log.i(TAG, "📍 GPS Location получен: " + vessel.getLatitude() + ", " + vessel.getLongitude());
- Log.i(TAG, "📍 Точность: " + vessel.getAccuracy() + "м");
-
- // Обновляем координаты
- testVessel.setLatitude(vessel.getLatitude());
- testVessel.setLongitude(vessel.getLongitude());
- testVessel.setAccuracy(vessel.getAccuracy());
- testVessel.setFixTime(vessel.getFixTime());
- testVessel.setFixQuality(vessel.getFixQuality());
-
- logVesselStatus();
- }
-
- @Override
- public void onGPSStatusChanged(int status) {
- Log.i(TAG, "GPS статус: " + status);
- }
-
- @Override
- public void onError(String error) {
- Log.e(TAG, "GPS Location ошибка: " + error);
- }
- });
-
- nmeaParser.setListener(new NMEAParser.NMEAParserListener() {
- @Override
- public void onVesselUpdated(Vessel vessel) {
- Log.i(TAG, "📡 NMEA данные получены: course=" + vessel.getCourse() +
- ", speed=" + vessel.getSpeed() +
- ", satellites=" + vessel.getSatellites());
-
- // Обновляем дополнительные данные
- if (vessel.getCourse() > 0) testVessel.setCourse(vessel.getCourse());
- if (vessel.getSpeed() > 0) testVessel.setSpeed(vessel.getSpeed());
- if (vessel.getSatellites() > 0) testVessel.setSatellites(vessel.getSatellites());
- if (vessel.getAltitude() != 0) testVessel.setAltitude(vessel.getAltitude());
-
- logVesselStatus();
- }
-
- @Override
- public void onAISVesselUpdated(com.grigowashere.aismap.models.AISVessel vessel) {
- Log.i(TAG, "🚢 AIS судно: " + vessel);
- }
-
- @Override
- public void onParseError(String error) {
- Log.e(TAG, "NMEA ошибка: " + error);
- }
-
- @Override
- public void onDOPUpdated(double pdop, double hdop, double vdop) {
- Log.i(TAG, "📊 DOP: PDOP=" + pdop + ", HDOP=" + hdop + ", VDOP=" + vdop);
-
- testVessel.setPdop(pdop);
- testVessel.setHdop(hdop);
- testVessel.setVdop(vdop);
-
- logVesselStatus();
- }
- });
- }
-
- /**
- * Запускает тест
- */
- public void startTest() {
- Log.i(TAG, "🚀 Запускаем тест гибридного GPS подхода...");
-
- // Запускаем GPS Location Listener
- boolean gpsSuccess = gpsLocationListener.startListening();
- if (gpsSuccess) {
- Log.i(TAG, "✅ GPS Location Listener запущен");
- } else {
- Log.e(TAG, "❌ Не удалось запустить GPS Location Listener");
- }
-
- // Тестируем NMEA парсер с тестовыми сообщениями
- testNMEAParser();
- }
-
- /**
- * Тестирует NMEA парсер
- */
- private void testNMEAParser() {
- Log.i(TAG, "🧪 Тестируем NMEA парсер...");
-
- // Тестовые NMEA сообщения
- String[] testMessages = {
- // GGA - количество спутников и высота
- "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47",
-
- // RMC - курс и скорость
- "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A",
-
- // VTG - курс и скорость (альтернативный источник)
- "$GPVTG,054.7,T,034.4,M,005.5,N,010.2,K*48",
-
- // GSA - DOP и активные спутники
- "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.2,0.8,1.0*3E",
-
- // GSV - спутники в поле зрения
- "$GPGSV,3,1,12,01,05,040,3,02,46,000,4,03,42,350,4,04,42,000,4*7F"
- };
-
- for (String message : testMessages) {
- Log.i(TAG, "📡 Тестируем NMEA: " + message);
- nmeaParser.parseNMEA(message);
- }
- }
-
- /**
- * Логирует текущий статус судна
- */
- private void logVesselStatus() {
- Log.i(TAG, "🚢 === СТАТУС СУДНА ===");
- Log.i(TAG, "📍 Координаты: " + testVessel.getLatitude() + ", " + testVessel.getLongitude());
- Log.i(TAG, "🎯 Точность: " + testVessel.getAccuracy() + "м (" + testVessel.getGPSQualityDescription() + ")");
- Log.i(TAG, "🧭 Курс: " + testVessel.getCourse() + "°");
- Log.i(TAG, "⚡ Скорость: " + testVessel.getSpeed() + " узлов");
- Log.i(TAG, "Спутники: " + testVessel.getSatellites() + "/" + testVessel.getActiveSatellites());
- Log.i(TAG, "📊 DOP: PDOP=" + testVessel.getPdop() + ", HDOP=" + testVessel.getHdop() + ", VDOP=" + testVessel.getVdop());
- Log.i(TAG, "🏔️ Высота: " + testVessel.getAltitude() + "м");
- Log.i(TAG, "🔧 Качество фикса: " + testVessel.getFixQuality());
- Log.i(TAG, "==========================");
- }
-
- /**
- * Останавливает тест
- */
- public void stopTest() {
- Log.i(TAG, "⏹️ Останавливаем тест...");
-
- if (gpsLocationListener != null) {
- gpsLocationListener.stopListening();
- }
-
- Log.i(TAG, "✅ Тест остановлен");
- }
-
- /**
- * Освобождает ресурсы
- */
- public void cleanup() {
- if (gpsLocationListener != null) {
- gpsLocationListener.cleanup();
- }
- }
-}
diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java
index 6afa31d..58c23d5 100644
--- a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java
+++ b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java
@@ -23,7 +23,7 @@ public class NMEAParser {
);
private static final Pattern RMC_PATTERN = Pattern.compile(
- "\\$G[PN]RMC,(\\d{6}\\.\\d{2}),([AV]),(\\d{4}\\.\\d+),([NS]),(\\d{5}\\.\\d+),([EW]),([^,]*),([^,]*),(\\d{6}),([^,]*),([^,]*),([^,]*),([^,]*)\\*([0-9A-F]{2})"
+ "\\$G[PN]RMC,(\\d{6}\\.\\d{2}),([AV][^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),(\\d{6}),([^,]*),([^,]*),([^,]*),?([^,]*)?\\*([0-9A-F]{2})"
);
private static final Pattern VTG_PATTERN = Pattern.compile(
@@ -44,7 +44,17 @@ public class NMEAParser {
// Паттерн для GSA сообщения (DOP и активные спутники)
private static final Pattern GSA_PATTERN = Pattern.compile(
- "\\$G[PN]GSA,([AM]),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+),([^,]*),([^,]*),([^,]*)\\*([0-9A-F]{2})"
+ "\\$G[PN]GSA,([AM]),(\\d+),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*)\\*([0-9A-F]{2})"
+ );
+
+ // Паттерн для обрезанных GSA сообщений
+ private static final Pattern GSA_TRUNCATED_PATTERN = Pattern.compile(
+ "\\$G[PN]GSA,([^,]*),([^,]*),([^,]*)\\*([0-9A-F]{2})"
+ );
+
+ // Паттерн для ZDA сообщения (Date and Time)
+ private static final Pattern ZDA_PATTERN = Pattern.compile(
+ "\\$G[PN]ZDA,(\\d{6}\\.\\d{2}),(\\d{2}),(\\d{2}),(\\d{4}),(\\d{2}),(\\d{2})\\*([0-9A-F]{2})"
);
private static final Pattern AIS_PATTERN = Pattern.compile(
@@ -97,7 +107,9 @@ public class NMEAParser {
*/
public void setHybridMode(boolean enabled) {
this.hybridMode = enabled;
- Log.i(TAG, "Гибридный режим: " + (enabled ? "включен" : "отключен"));
+ Log.i(TAG, "🔄 Гибридный режим: " + (enabled ? "включен" : "отключен"));
+ Log.i(TAG, "📍 В режиме " + (enabled ? "гибридном" : "только NMEA") + " координаты будут " +
+ (enabled ? "браться из Android GPS API" : "браться из NMEA сообщений"));
}
/**
@@ -110,6 +122,10 @@ public class NMEAParser {
// Очищаем сообщение от лишних символов
String cleanedSentence = cleanNMEASentence(nmeaSentence);
+ if (cleanedSentence == null) {
+ Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence);
+ return;
+ }
Log.d(TAG, "Парсим NMEA: " + cleanedSentence);
try {
@@ -121,12 +137,14 @@ public class NMEAParser {
parseVTG(cleanedSentence);
} else if (cleanedSentence.startsWith("$GPGLL") || cleanedSentence.startsWith("$GNGLL")) {
parseGLL(cleanedSentence);
- } else if (cleanedSentence.startsWith("$GPGSV") || cleanedSentence.startsWith("$GAGSV") || cleanedSentence.startsWith("$GLGSV") || cleanedSentence.startsWith("$GNGSA")) {
+ } else if (cleanedSentence.startsWith("$GPGSV") || cleanedSentence.startsWith("$GAGSV") || cleanedSentence.startsWith("$GLGSV") || cleanedSentence.startsWith("$GBGSV") || cleanedSentence.startsWith("$GNGSA")) {
parseGSV(cleanedSentence);
} else if (cleanedSentence.startsWith("$GNGNS")) {
parseGNS(cleanedSentence);
} else if (cleanedSentence.startsWith("$GPGSA") || cleanedSentence.startsWith("$GNGSA")) {
parseGSA(cleanedSentence);
+ } else if (cleanedSentence.startsWith("$GPZDA") || cleanedSentence.startsWith("$GNZDA")) {
+ parseZDA(cleanedSentence);
} else if (cleanedSentence.startsWith("!AIVDM")) {
parseAIS(cleanedSentence);
} else {
@@ -144,17 +162,57 @@ public class NMEAParser {
* Очищает NMEA сообщение от лишних символов
*/
private String cleanNMEASentence(String sentence) {
- if (sentence == null) {
+ if (sentence == null || sentence.trim().isEmpty()) {
return null;
}
// Убираем пробелы в начале и конце
String cleaned = sentence.trim();
+ // Проверяем минимальную длину NMEA сообщения
+ if (cleaned.length() < 6) { // Минимум: $GPGGA*XX
+ Log.w(TAG, "Слишком короткое NMEA сообщение: '" + cleaned + "'");
+ return null;
+ }
+
+ // Исправляем двойной $ ($$GNGGA -> $GNGGA)
+ if (cleaned.startsWith("$$")) {
+ cleaned = cleaned.substring(1);
+ Log.d(TAG, "Исправлен двойной $: " + cleaned);
+ }
+
+ // Обрабатываем смешанные сообщения (например, VTG содержит GGA)
+ if (cleaned.contains("$G") && cleaned.indexOf("$G") > 0) {
+ // Находим первое полное NMEA сообщение
+ int firstDollar = cleaned.indexOf("$G");
+ if (firstDollar > 0) {
+ String firstMessage = cleaned.substring(firstDollar);
+ int asteriskIndex = firstMessage.indexOf('*');
+ if (asteriskIndex > 0) {
+ // Проверяем, что после * есть достаточно символов для контрольной суммы
+ if (asteriskIndex + 2 < firstMessage.length()) {
+ cleaned = firstMessage.substring(0, asteriskIndex + 3);
+ } else if (asteriskIndex + 1 < firstMessage.length()) {
+ cleaned = firstMessage.substring(0, asteriskIndex + 2);
+ } else {
+ cleaned = firstMessage.substring(0, asteriskIndex + 1);
+ }
+ Log.d(TAG, "Извлечено первое NMEA сообщение: " + cleaned);
+ }
+ }
+ }
+
// Убираем все символы после последнего *
int asteriskIndex = cleaned.lastIndexOf('*');
if (asteriskIndex >= 0) {
- cleaned = cleaned.substring(0, asteriskIndex + 3); // включаем * и 2 символа контрольной суммы
+ // Проверяем, что после * есть достаточно символов для контрольной суммы
+ if (asteriskIndex + 2 < cleaned.length()) {
+ cleaned = cleaned.substring(0, asteriskIndex + 3); // включаем * и 2 символа контрольной суммы
+ } else if (asteriskIndex + 1 < cleaned.length()) {
+ cleaned = cleaned.substring(0, asteriskIndex + 2); // включаем * и 1 символ контрольной суммы
+ } else {
+ cleaned = cleaned.substring(0, asteriskIndex + 1); // включаем только *
+ }
}
// Убираем все непечатаемые символы
@@ -235,41 +293,47 @@ public class NMEAParser {
* В гибридном режиме используем только курс и скорость
*/
private void parseRMC(String rmc) {
-// Log.d(TAG, "Парсим RMC: " + rmc);
-// Log.d(TAG, "Применяем паттерн RMC: " + RMC_PATTERN.pattern());
+ Log.d(TAG, "Парсим RMC: " + rmc);
+ Log.d(TAG, "Применяем паттерн RMC: " + RMC_PATTERN.pattern());
Matcher matcher = RMC_PATTERN.matcher(rmc);
if (matcher.matches()) {
-// Log.d(TAG, "RMC совпадает с паттерном");
+ Log.d(TAG, "RMC совпадает с паттерном");
- // Обрабатываем скорость - может быть пустым полем (теперь в группе 7)
+ // Проверяем статус валидности (группа 2)
+ String status = matcher.group(2);
+ boolean isValid = status != null && status.startsWith("A");
+ Log.d(TAG, "RMC статус: " + status + " (валидный: " + isValid + ")");
+
+ // Обрабатываем скорость - может быть пустым полем (группа 7)
double speed = 0.0;
String speedStr = matcher.group(7);
if (speedStr != null && !speedStr.trim().isEmpty()) {
try {
speed = Double.parseDouble(speedStr);
} catch (NumberFormatException e) {
-// Log.w(TAG, "Не удалось распарсить скорость RMC: '" + speedStr + "', используем 0.0");
+ Log.w(TAG, "Не удалось распарсить скорость RMC: '" + speedStr + "', используем 0.0");
speed = 0.0;
}
}
- // Обрабатываем курс - может быть пустым полем (теперь в группе 8)
+ // Обрабатываем курс - может быть пустым полем (группа 8)
double course = 0.0;
String courseStr = matcher.group(8);
if (courseStr != null && !courseStr.trim().isEmpty()) {
try {
course = Double.parseDouble(courseStr);
} catch (NumberFormatException e) {
-// Log.w(TAG, "Не удалось распарсить курс: '" + courseStr + "', используем 0.0");
+ Log.w(TAG, "Не удалось распарсить курс: '" + courseStr + "', используем 0.0");
course = 0.0;
}
}
-// Log.d(TAG, String.format("RMC: speed=%.1f, course=%.1f", speed, course));
+ Log.d(TAG, String.format("RMC: speed=%.1f, course=%.1f, valid=%s", speed, course, isValid));
// В гибридном режиме не обновляем координаты
- if (!hybridMode) {
+ if (!hybridMode && isValid) {
+ Log.d(TAG, "Режим НЕ гибридный - обрабатываем координаты из RMC");
// Обрабатываем координаты - могут быть пустыми полями (группы 3,4,5,6)
double latitude = 0.0;
double longitude = 0.0;
@@ -278,26 +342,43 @@ public class NMEAParser {
String latDir = matcher.group(4);
if (latStr != null && !latStr.trim().isEmpty() && latDir != null && !latDir.trim().isEmpty()) {
latitude = parseCoordinate(latStr, latDir.equals("N"));
+ Log.d(TAG, "RMC широта: " + latStr + " " + latDir + " = " + latitude);
}
String lonStr = matcher.group(5);
String lonDir = matcher.group(6);
if (lonStr != null && !lonStr.trim().isEmpty() && lonDir != null && !lonDir.trim().isEmpty()) {
longitude = parseCoordinate(lonStr, lonDir.equals("E"));
+ Log.d(TAG, "RMC долгота: " + lonStr + " " + lonDir + " = " + longitude);
}
+ Log.d(TAG, "RMC устанавливаем координаты: lat=" + latitude + ", lon=" + longitude);
ownVessel.setLatitude(latitude);
ownVessel.setLongitude(longitude);
+ } else if (hybridMode) {
+ Log.d(TAG, "Гибридный режим - координаты из RMC игнорируются");
+ } else {
+ Log.d(TAG, "RMC данные невалидны (статус V) - координаты не обновляем");
}
- ownVessel.setSpeed(speed);
- ownVessel.setCourse(course);
+ // Обновляем скорость и курс только если данные валидны
+ if (isValid) {
+ ownVessel.setSpeed(speed);
+ ownVessel.setCourse(course);
+ }
+
+ Log.d(TAG, "RMC обновлено судно: lat=" + ownVessel.getLatitude() +
+ ", lon=" + ownVessel.getLongitude() +
+ ", speed=" + speed +
+ ", course=" + course);
if (listener != null) {
listener.onVesselUpdated(ownVessel);
}
} else {
-// Log.w(TAG, "RMC не совпадает с паттерном");
+ Log.w(TAG, "RMC не совпадает с паттерном");
+ Log.w(TAG, "Сообщение: '" + rmc + "'");
+ Log.w(TAG, "Паттерн: " + RMC_PATTERN.pattern());
}
}
@@ -406,6 +487,8 @@ public class NMEAParser {
systemType = "GLONASS";
} else if (gsv.startsWith("$GAGSV")) {
systemType = "Galileo";
+ } else if (gsv.startsWith("$GBGSV")) {
+ systemType = "BeiDou";
} else if (gsv.startsWith("$GNGSA")) {
systemType = "GNSS";
}
@@ -448,6 +531,10 @@ public class NMEAParser {
case "Galileo":
galileoSatellites = satellitesInView;
break;
+ case "BeiDou":
+ // Пока не добавляем отдельный счетчик для BeiDou, считаем как GPS
+ gpsSatellites = Math.max(gpsSatellites, satellitesInView);
+ break;
}
// Обновляем общее количество спутников
@@ -537,6 +624,45 @@ public class NMEAParser {
}
}
+ /**
+ * Парсит ZDA сообщение (Date and Time)
+ */
+ private void parseZDA(String zda) {
+ Log.d(TAG, "Парсим ZDA: " + zda);
+ Matcher matcher = ZDA_PATTERN.matcher(zda);
+ if (matcher.matches()) {
+ try {
+ // Время (HHMMSS.SS)
+ String timeStr = matcher.group(1);
+ // День (DD)
+ int day = Integer.parseInt(matcher.group(2));
+ // Месяц (MM)
+ int month = Integer.parseInt(matcher.group(3));
+ // Год (YYYY)
+ int year = Integer.parseInt(matcher.group(4));
+ // Часовой пояс (часы)
+ int timezoneHours = Integer.parseInt(matcher.group(5));
+ // Часовой пояс (минуты)
+ int timezoneMinutes = Integer.parseInt(matcher.group(6));
+
+ Log.d(TAG, String.format("ZDA: %04d-%02d-%02d %s, TZ: %+03d:%02d",
+ year, month, day, timeStr, timezoneHours, timezoneMinutes));
+
+ // Обновляем время последнего обновления
+ ownVessel.setLastUpdate(java.time.LocalDateTime.now());
+
+ if (listener != null) {
+ listener.onVesselUpdated(ownVessel);
+ }
+
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ошибка парсинга ZDA: " + e.getMessage());
+ }
+ } else {
+ Log.w(TAG, "ZDA не совпадает с паттерном: " + zda);
+ }
+ }
+
/**
* Парсит GSA сообщение (GPS DOP and Active Satellites)
* КЛЮЧЕВОЕ сообщение для получения DOP и активных спутников
@@ -544,15 +670,18 @@ public class NMEAParser {
private void parseGSA(String gsa) {
Log.d(TAG, "Парсим GSA: " + gsa);
Matcher matcher = GSA_PATTERN.matcher(gsa);
+ Matcher truncatedMatcher = GSA_TRUNCATED_PATTERN.matcher(gsa);
+
if (matcher.matches()) {
-// Log.d(TAG, "GSA совпадает с паттерном");
+ Log.d(TAG, "GSA совпадает с паттерном");
// Подсчитываем активные спутники (непустые поля)
int activeSatellites = 0;
- for (int i = 2; i <= 13; i++) {
+ for (int i = 3; i <= 14; i++) { // Группы 3-14 содержат ID спутников
String satId = matcher.group(i);
if (satId != null && !satId.trim().isEmpty() && !satId.equals("0")) {
activeSatellites++;
+ Log.d(TAG, "Активный спутник: " + satId);
}
}
@@ -561,35 +690,35 @@ public class NMEAParser {
double hdop = 0.0;
double vdop = 0.0;
- String pdopStr = matcher.group(14);
+ String pdopStr = matcher.group(15); // PDOP в группе 15
if (pdopStr != null && !pdopStr.trim().isEmpty()) {
try {
pdop = Double.parseDouble(pdopStr);
} catch (NumberFormatException e) {
-// Log.w(TAG, "Не удалось распарсить PDOP: '" + pdopStr + "', используем 0.0");
+ Log.w(TAG, "Не удалось распарсить PDOP: '" + pdopStr + "', используем 0.0");
}
}
- String hdopStr = matcher.group(15);
+ String hdopStr = matcher.group(16); // HDOP в группе 16
if (hdopStr != null && !hdopStr.trim().isEmpty()) {
try {
hdop = Double.parseDouble(hdopStr);
} catch (NumberFormatException e) {
-// Log.w(TAG, "Не удалось распарсить HDOP: '" + hdopStr + "', используем 0.0");
+ Log.w(TAG, "Не удалось распарсить HDOP: '" + hdopStr + "', используем 0.0");
}
}
- String vdopStr = matcher.group(16);
+ String vdopStr = matcher.group(17); // VDOP в группе 17
if (vdopStr != null && !vdopStr.trim().isEmpty()) {
try {
vdop = Double.parseDouble(vdopStr);
} catch (NumberFormatException e) {
-// Log.w(TAG, "Не удалось распарсить VDOP: '" + vdopStr + "', используем 0.0");
+ Log.w(TAG, "Не удалось распарсить VDOP: '" + vdopStr + "', используем 0.0");
}
}
-// Log.d(TAG, String.format("GSA: активных спутников=%d, PDOP=%.2f, HDOP=%.2f, VDOP=%.2f",
-// activeSatellites, pdop, hdop, vdop));
+ Log.d(TAG, String.format("GSA: активных спутников=%d, PDOP=%.2f, HDOP=%.2f, VDOP=%.2f",
+ activeSatellites, pdop, hdop, vdop));
// Обновляем информацию о спутниках
ownVessel.setActiveSatellites(activeSatellites);
@@ -604,13 +733,59 @@ public class NMEAParser {
gpsLocationListener.setSatellitesInVessel(ownVessel);
}
+ // Уведомляем слушателя о DOP
+ if (listener != null) {
+ listener.onDOPUpdated(pdop, hdop, vdop);
+ listener.onVesselUpdated(ownVessel);
+ }
+ } else if (truncatedMatcher.matches()) {
+ Log.d(TAG, "GSA совпадает с обрезанным паттерном");
+
+ // Обрабатываем обрезанное GSA сообщение
+ String pdopStr = truncatedMatcher.group(1);
+ String hdopStr = truncatedMatcher.group(2);
+ String vdopStr = truncatedMatcher.group(3);
+
+ double pdop = 0.0;
+ double hdop = 0.0;
+ double vdop = 0.0;
+
+ try {
+ if (pdopStr != null && !pdopStr.trim().isEmpty()) {
+ pdop = Double.parseDouble(pdopStr);
+ }
+ if (hdopStr != null && !hdopStr.trim().isEmpty()) {
+ hdop = Double.parseDouble(hdopStr);
+ }
+ if (vdopStr != null && !vdopStr.trim().isEmpty()) {
+ vdop = Double.parseDouble(vdopStr);
+ }
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ошибка парсинга DOP в обрезанном GSA: " + e.getMessage());
+ }
+
+ Log.d(TAG, String.format("GSA (обрезанное): PDOP=%.2f, HDOP=%.2f, VDOP=%.2f", pdop, hdop, vdop));
+
+ // Обновляем DOP значения
+ ownVessel.setPdop(pdop);
+ ownVessel.setHdop(hdop);
+ ownVessel.setVdop(vdop);
+
+ // Отправляем DOP значения в GPS Location Listener
+ if (gpsLocationListener != null) {
+ gpsLocationListener.setDOPValues(pdop, hdop, vdop);
+ }
+
// Уведомляем слушателя о DOP
if (listener != null) {
listener.onDOPUpdated(pdop, hdop, vdop);
listener.onVesselUpdated(ownVessel);
}
} else {
- Log.w(TAG, "GSA не совпадает с паттерном");
+ Log.w(TAG, "GSA не совпадает ни с одним паттерном");
+ Log.w(TAG, "Сообщение: '" + gsa + "'");
+ Log.w(TAG, "Паттерн: " + GSA_PATTERN.pattern());
+ Log.w(TAG, "Обрезанный паттерн: " + GSA_TRUNCATED_PATTERN.pattern());
}
}
diff --git a/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java
new file mode 100644
index 0000000..27bb514
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java
@@ -0,0 +1,202 @@
+package com.grigowashere.aismap.utils;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+/**
+ * Менеджер настроек приложения
+ * Управляет сохранением и загрузкой настроек в SharedPreferences
+ */
+public class SettingsManager {
+
+ private static final String TAG = "SettingsManager";
+ private static final String PREFS_NAME = "AISMapSettings";
+
+ // Ключи для настроек
+ private static final String KEY_UDP_PORT = "udp_port";
+ private static final String KEY_UDP_ENABLED = "udp_enabled";
+ private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled";
+ private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled";
+ private static final String KEY_DATA_MODE = "data_mode";
+
+ // Значения по умолчанию
+ private static final int DEFAULT_UDP_PORT = 10110;
+ private static final boolean DEFAULT_UDP_ENABLED = true;
+ private static final boolean DEFAULT_ANDROID_NMEA_ENABLED = true;
+ private static final boolean DEFAULT_UDP_NMEA_ENABLED = true;
+ private static final String DEFAULT_DATA_MODE = "hybrid";
+
+ // Режимы работы с данными
+ public static final String DATA_MODE_HYBRID = "hybrid";
+ public static final String DATA_MODE_NMEA_ONLY = "nmea_only";
+ public static final String DATA_MODE_ANDROID_ONLY = "android_only";
+
+ private Context context;
+ private SharedPreferences prefs;
+
+ public SettingsManager(Context context) {
+ this.context = context;
+ this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ }
+
+ /**
+ * Получает UDP порт
+ */
+ public int getUDPPort() {
+ return prefs.getInt(KEY_UDP_PORT, DEFAULT_UDP_PORT);
+ }
+
+ /**
+ * Устанавливает UDP порт
+ */
+ public void setUDPPort(int port) {
+ if (port < 1 || port > 65535) {
+ Log.w(TAG, "Некорректный порт: " + port + ", используем значение по умолчанию");
+ port = DEFAULT_UDP_PORT;
+ }
+ prefs.edit().putInt(KEY_UDP_PORT, port).apply();
+ Log.i(TAG, "UDP порт установлен: " + port);
+ }
+
+ /**
+ * Проверяет, включен ли UDP слушатель
+ */
+ public boolean isUDPEnabled() {
+ return prefs.getBoolean(KEY_UDP_ENABLED, DEFAULT_UDP_ENABLED);
+ }
+
+ /**
+ * Включает/выключает UDP слушатель
+ */
+ public void setUDPEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_UDP_ENABLED, enabled).apply();
+ Log.i(TAG, "UDP слушатель: " + (enabled ? "включен" : "выключен"));
+ }
+
+ /**
+ * Проверяет, включен ли Android NMEA
+ */
+ public boolean isAndroidNMEAEnabled() {
+ return prefs.getBoolean(KEY_ANDROID_NMEA_ENABLED, DEFAULT_ANDROID_NMEA_ENABLED);
+ }
+
+ /**
+ * Включает/выключает Android NMEA
+ */
+ public void setAndroidNMEAEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_ANDROID_NMEA_ENABLED, enabled).apply();
+ Log.i(TAG, "Android NMEA: " + (enabled ? "включен" : "выключен"));
+ }
+
+ /**
+ * Проверяет, включен ли UDP NMEA
+ */
+ public boolean isUDPNMEAEnabled() {
+ return prefs.getBoolean(KEY_UDP_NMEA_ENABLED, DEFAULT_UDP_NMEA_ENABLED);
+ }
+
+ /**
+ * Включает/выключает UDP NMEA
+ */
+ public void setUDPNMEAEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_UDP_NMEA_ENABLED, enabled).apply();
+ Log.i(TAG, "UDP NMEA: " + (enabled ? "включен" : "выключен"));
+ }
+
+ /**
+ * Получает режим работы с данными
+ */
+ public String getDataMode() {
+ return prefs.getString(KEY_DATA_MODE, DEFAULT_DATA_MODE);
+ }
+
+ /**
+ * Устанавливает режим работы с данными
+ */
+ public void setDataMode(String mode) {
+ if (!isValidDataMode(mode)) {
+ Log.w(TAG, "Некорректный режим данных: " + mode + ", используем значение по умолчанию");
+ mode = DEFAULT_DATA_MODE;
+ }
+ prefs.edit().putString(KEY_DATA_MODE, mode).apply();
+ Log.i(TAG, "Режим данных установлен: " + mode);
+ }
+
+ /**
+ * Проверяет корректность режима данных
+ */
+ private boolean isValidDataMode(String mode) {
+ return DATA_MODE_HYBRID.equals(mode) ||
+ DATA_MODE_NMEA_ONLY.equals(mode) ||
+ DATA_MODE_ANDROID_ONLY.equals(mode);
+ }
+
+ /**
+ * Проверяет, включен ли гибридный режим
+ */
+ public boolean isHybridMode() {
+ return DATA_MODE_HYBRID.equals(getDataMode());
+ }
+
+ /**
+ * Проверяет, включен ли режим только NMEA
+ */
+ public boolean isNMEAOnlyMode() {
+ return DATA_MODE_NMEA_ONLY.equals(getDataMode());
+ }
+
+ /**
+ * Проверяет, включен ли режим только Android GPS
+ */
+ public boolean isAndroidOnlyMode() {
+ return DATA_MODE_ANDROID_ONLY.equals(getDataMode());
+ }
+
+ /**
+ * Сбрасывает все настройки к значениям по умолчанию
+ */
+ public void resetToDefaults() {
+ prefs.edit()
+ .putInt(KEY_UDP_PORT, DEFAULT_UDP_PORT)
+ .putBoolean(KEY_UDP_ENABLED, DEFAULT_UDP_ENABLED)
+ .putBoolean(KEY_ANDROID_NMEA_ENABLED, DEFAULT_ANDROID_NMEA_ENABLED)
+ .putBoolean(KEY_UDP_NMEA_ENABLED, DEFAULT_UDP_NMEA_ENABLED)
+ .putString(KEY_DATA_MODE, DEFAULT_DATA_MODE)
+ .apply();
+ Log.i(TAG, "Настройки сброшены к значениям по умолчанию");
+ }
+
+ /**
+ * Получает все настройки в виде строки для отладки
+ */
+ public String getSettingsSummary() {
+ return String.format(
+ "UDP: порт=%d, включен=%s\n" +
+ "Android NMEA: %s\n" +
+ "UDP NMEA: %s\n" +
+ "Режим данных: %s",
+ getUDPPort(),
+ isUDPEnabled() ? "да" : "нет",
+ isAndroidNMEAEnabled() ? "включен" : "выключен",
+ isUDPNMEAEnabled() ? "включен" : "выключен",
+ getDataMode()
+ );
+ }
+
+ /**
+ * Проверяет, нужно ли перезапустить UDP слушатель
+ */
+ public boolean shouldRestartUDP(int currentPort, boolean currentEnabled) {
+ return getUDPPort() != currentPort || isUDPEnabled() != currentEnabled;
+ }
+
+ /**
+ * Проверяет, нужно ли перезапустить NMEA парсер
+ */
+ public boolean shouldRestartNMEA(boolean currentAndroidNMEA, boolean currentUDPNMEA, String currentDataMode) {
+ return isAndroidNMEAEnabled() != currentAndroidNMEA ||
+ isUDPNMEAEnabled() != currentUDPNMEA ||
+ !getDataMode().equals(currentDataMode);
+ }
+}
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 52e3835..1ade483 100644
--- a/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java
+++ b/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java
@@ -53,6 +53,12 @@ public abstract class BaseDockWidget extends FrameLayout {
}
protected OnDockResizeListener dockResizeListener;
+ // Интерфейс для уведомления об изменении состояния docked
+ public interface OnDockStateChangeListener {
+ void onDockStateChanged(boolean isDocked, boolean isTop);
+ }
+ protected OnDockStateChangeListener dockStateChangeListener;
+
public BaseDockWidget(Context context) {
super(context);
init();
@@ -246,12 +252,23 @@ public abstract class BaseDockWidget extends FrameLayout {
dockHeightPx = newHeight;
setLayoutParams(lp);
- // Если закреплен снизу, нужно также изменить позицию Y
- if (!dockTop) {
+ // Корректируем позицию Y в зависимости от позиции закрепления
+ if (dockTop) {
+ // Если закреплен сверху, позиция Y всегда должна быть 0
+ setY(0);
+ } else {
+ // Если закреплен снизу, позиция Y должна быть (parentHeight - newHeight)
float newY = ((ViewGroup) getParent()).getHeight() - newHeight;
setY(newY);
}
+ // Перепозиционируем все docked виджеты после изменения размера
+ ViewGroup parent = (ViewGroup) getParent();
+ if (parent != null) {
+ repositionAllDockedWidgets(parent);
+ }
+
+ // Уведомляем об изменении размера
if (dockResizeListener != null) {
dockResizeListener.onDockResize(newHeight);
}
@@ -362,9 +379,13 @@ public abstract class BaseDockWidget extends FrameLayout {
float endX = targetX;
float endY = targetY;
- // Если доким в нижнюю часть, корректируем позицию Y
- if (docked && !top) {
- endY = parentHeight - dockHeight;
+ // Если доким, вычисляем правильную позицию с учетом других docked виджетов
+ if (docked) {
+ endX = 0;
+ endY = calculateDockPosition(top);
+ } else {
+ // При переходе в movable режим сбрасываем размер до дефолтного
+ dockHeightPx = 0;
}
// Сохраняем финальные значения для использования в lambda и inner class
@@ -414,6 +435,11 @@ public abstract class BaseDockWidget extends FrameLayout {
morphAnimator.start();
this.isDocked = docked;
isMorphing = true;
+
+ // Уведомляем об изменении состояния docked
+ if (dockStateChangeListener != null) {
+ dockStateChangeListener.onDockStateChanged(docked, top);
+ }
}
public boolean isDocked() {
@@ -432,10 +458,101 @@ public abstract class BaseDockWidget extends FrameLayout {
this.dockResizeListener = listener;
}
+ public void setOnDockStateChangeListener(OnDockStateChangeListener listener) {
+ this.dockStateChangeListener = listener;
+ }
+
protected float dp(float dp) {
return dp * getResources().getDisplayMetrics().density;
}
+ /**
+ * Вычисляет правильную позицию для докинга с учетом других docked виджетов
+ */
+ protected float calculateDockPosition(boolean dockTop) {
+ ViewGroup parent = (ViewGroup) getParent();
+ if (parent == null) return 0;
+
+ int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
+ float y = 0;
+
+ if (dockTop) {
+ // Доким сверху - начинаем с позиции 0
+ y = 0;
+
+ // Проверяем другие виджеты сверху
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ View child = parent.getChildAt(i);
+ if (child != this && child instanceof BaseDockWidget) {
+ BaseDockWidget otherWidget = (BaseDockWidget) child;
+ if (otherWidget.isDocked() && otherWidget.isDockTop()) {
+ // Если другой виджет уже docked сверху, ставим наш под ним
+ y = otherWidget.getY() + otherWidget.getHeight();
+ break;
+ }
+ }
+ }
+ } else {
+ // Доким снизу - начинаем с нижней позиции
+ y = parent.getHeight() - dockHeight;
+
+ // Проверяем другие виджеты снизу
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ View child = parent.getChildAt(i);
+ if (child != this && child instanceof BaseDockWidget) {
+ BaseDockWidget otherWidget = (BaseDockWidget) child;
+ if (otherWidget.isDocked() && !otherWidget.isDockTop()) {
+ // Если другой виджет уже docked снизу, ставим наш над ним
+ y = otherWidget.getY() - dockHeight;
+ break;
+ }
+ }
+ }
+ }
+
+ return y;
+ }
+
+ /**
+ * Перепозиционирует все docked виджеты, чтобы они прижались к краям
+ */
+ 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);
+ }
+ }
+ }
+ }
+
+ // Перепозиционируем виджеты сверху
+ 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);
+ }
+ }
+
// Абстрактные методы для переопределения в наследниках
protected abstract void onDrawDock(Canvas canvas);
protected abstract void onDrawCircle(Canvas canvas);
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 b7855ad..832261e 100644
--- a/app/src/main/java/com/grigowashere/aismap/view/CompassView.java
+++ b/app/src/main/java/com/grigowashere/aismap/view/CompassView.java
@@ -80,7 +80,7 @@ public class CompassView extends BaseDockWidget {
// Прямая шкала (dock-режим)
@Override
protected void onDrawDock(Canvas canvas) {
- Log.d(TAG, "onDrawDock called, width=" + getWidth() + ", height=" + getHeight());
+ // Log.d(TAG, "onDrawDock called, width=" + getWidth() + ", height=" + getHeight());
float w = getWidth();
float h = getHeight();
@@ -192,13 +192,13 @@ public class CompassView extends BaseDockWidget {
// Круглый компас (draggable-режим)
@Override
protected void onDrawCircle(Canvas canvas) {
- Log.d(TAG, "onDrawCircle called, width=" + getWidth() + ", height=" + getHeight());
+ //Log.d(TAG, "onDrawCircle called, width=" + getWidth() + ", height=" + getHeight());
float w = getWidth();
float h = getHeight();
if (w <= 0 || h <= 0) {
- Log.w(TAG, "Invalid dimensions: width=" + w + ", height=" + h);
+ // Log.w(TAG, "Invalid dimensions: width=" + w + ", height=" + h);
return;
}
diff --git a/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java
new file mode 100644
index 0000000..fa0a185
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java
@@ -0,0 +1,275 @@
+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 android.util.Log;
+import com.grigowashere.aismap.models.Vessel;
+
+public class CoordinatesDockWidget extends BaseDockWidget {
+ private static final String TAG = "CoordinatesDockWidget";
+
+ // Цвета
+ private static final int BACKGROUND_COLOR = 0xE6000000; // Полупрозрачный черный
+ private static final int TEXT_COLOR = 0xFFFFFFFF; // Белый
+ private static final int ACCENT_COLOR = 0xFF4CAF50; // Зеленый
+ private static final int WARNING_COLOR = 0xFFFF9800; // Оранжевый
+ private static final int ERROR_COLOR = 0xFFF44336; // Красный
+
+ // Кисти
+ private Paint backgroundPaint;
+ private Paint textPaint;
+ private Paint accentPaint;
+ private Paint warningPaint;
+ private Paint errorPaint;
+
+ // Данные для отображения
+ private Vessel vessel;
+ private String coordinatesText = "Координаты: --";
+ private String sogText = "SOG: --";
+ private String cogText = "COG: --";
+
+ public CoordinatesDockWidget(Context context) {
+ super(context);
+ init();
+ }
+
+ public CoordinatesDockWidget(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ // Инициализируем кисти
+ backgroundPaint = new Paint();
+ backgroundPaint.setColor(BACKGROUND_COLOR);
+ backgroundPaint.setStyle(Paint.Style.FILL);
+ backgroundPaint.setAntiAlias(true);
+
+ textPaint = new Paint();
+ textPaint.setColor(TEXT_COLOR);
+ textPaint.setTextSize(dp(14));
+ textPaint.setTypeface(Typeface.DEFAULT_BOLD);
+ textPaint.setAntiAlias(true);
+
+ accentPaint = new Paint();
+ accentPaint.setColor(ACCENT_COLOR);
+ accentPaint.setTextSize(dp(14));
+ accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
+ accentPaint.setAntiAlias(true);
+
+ warningPaint = new Paint();
+ warningPaint.setColor(WARNING_COLOR);
+ warningPaint.setTextSize(dp(14));
+ warningPaint.setTypeface(Typeface.DEFAULT_BOLD);
+ warningPaint.setAntiAlias(true);
+
+ errorPaint = new Paint();
+ errorPaint.setColor(ERROR_COLOR);
+ errorPaint.setTextSize(dp(14));
+ errorPaint.setTypeface(Typeface.DEFAULT_BOLD);
+ errorPaint.setAntiAlias(true);
+
+ // Устанавливаем фон для видимости (как в CompassView)
+ setBackgroundColor(android.graphics.Color.argb(200, 0, 0, 0));
+ }
+
+ /**
+ * Обновляет данные судна
+ */
+ public void updateVessel(Vessel vessel) {
+ Log.d(TAG, "updateVessel called with vessel: " + (vessel != null ? "not null" : "null"));
+ if (vessel != null) {
+ Log.d(TAG, "Vessel data: lat=" + vessel.getLatitude() +
+ ", lon=" + vessel.getLongitude() +
+ ", speed=" + vessel.getSpeed() +
+ ", course=" + vessel.getCourse());
+ }
+ this.vessel = vessel;
+ updateDisplayText();
+ invalidate();
+ }
+
+ /**
+ * Обновляет текст для отображения
+ */
+ private void updateDisplayText() {
+ if (vessel == null) {
+ coordinatesText = "Координаты: --";
+ sogText = "SOG: --";
+ cogText = "COG: --";
+ return;
+ }
+
+ // Координаты
+ if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
+ coordinatesText = String.format("📍 %.6f, %.6f",
+ vessel.getLatitude(), vessel.getLongitude());
+ } else {
+ coordinatesText = "📍 Координаты: --";
+ }
+
+ // SOG (Speed Over Ground)
+ if (vessel.getSpeed() > 0) {
+ sogText = String.format("⚡ SOG: %.1f уз", vessel.getSpeed());
+ } else {
+ sogText = "⚡ SOG: --";
+ }
+
+ // COG (Course Over Ground)
+ if (vessel.getCourse() > 0) {
+ cogText = String.format("🧭 COG: %.1f°", vessel.getCourse());
+ } else {
+ cogText = "🧭 COG: --";
+ }
+ }
+
+ @Override
+ protected void onDrawDock(Canvas canvas) {
+ int width = getWidth();
+ int height = getHeight();
+
+ Log.d(TAG, "onDrawDock called, width=" + width + ", height=" + height);
+
+ if (width <= 0 || height <= 0) {
+ Log.w(TAG, "Invalid dimensions: width=" + width + ", height=" + height);
+ return;
+ }
+
+ // Рисуем фон
+ canvas.drawRect(0, 0, width, height, backgroundPaint);
+
+ // Вычисляем позиции для текста
+ float textSize = dp(14);
+ float lineHeight = textSize * 1.2f;
+ float startY = (height - (lineHeight * 3)) / 2 + textSize;
+
+ // Определяем цвета в зависимости от качества данных
+ Paint coordinatesPaint = getCoordinatesPaint();
+ Paint sogPaint = getSOGPaint();
+ Paint cogPaint = getCOGPaint();
+
+ // Рисуем тестовый заголовок для проверки видимости
+ Paint testPaint = new Paint();
+ testPaint.setColor(android.graphics.Color.WHITE);
+ testPaint.setTextSize(dp(16));
+ testPaint.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
+ testPaint.setAntiAlias(true);
+ canvas.drawText("КООРДИНАТЫ", dp(16), dp(20), testPaint);
+
+ // Рисуем текст
+ canvas.drawText(coordinatesText, dp(16), startY, coordinatesPaint);
+ canvas.drawText(sogText, dp(16), startY + lineHeight, sogPaint);
+ canvas.drawText(cogText, dp(16), startY + lineHeight * 2, cogPaint);
+
+ // Рисуем разделительную линию сверху, если закреплен снизу
+ if (!isDockTop()) {
+ Paint linePaint = new Paint();
+ linePaint.setColor(ACCENT_COLOR);
+ linePaint.setStrokeWidth(dp(2));
+ canvas.drawLine(0, 0, width, 0, linePaint);
+ }
+ }
+
+ @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(8);
+
+ // Рисуем фон
+ canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
+
+ // Рисуем рамку
+ Paint borderPaint = new Paint();
+ borderPaint.setColor(ACCENT_COLOR);
+ borderPaint.setStyle(Paint.Style.STROKE);
+ borderPaint.setStrokeWidth(dp(3));
+ borderPaint.setAntiAlias(true);
+ canvas.drawCircle(centerX, centerY, radius, borderPaint);
+
+ // Вычисляем позиции для текста в круге
+ float textSize = dp(12);
+ float lineHeight = textSize * 1.3f;
+ float startY = centerY - lineHeight;
+
+ // Определяем цвета
+ Paint coordinatesPaint = getCoordinatesPaint();
+ Paint sogPaint = getSOGPaint();
+ Paint cogPaint = getCOGPaint();
+
+ // Центрируем текст
+ Rect textBounds = new Rect();
+
+ // Координаты
+ coordinatesPaint.getTextBounds(coordinatesText, 0, coordinatesText.length(), textBounds);
+ canvas.drawText(coordinatesText, centerX - textBounds.width() / 2f, startY, coordinatesPaint);
+
+ // SOG
+ sogPaint.getTextBounds(sogText, 0, sogText.length(), textBounds);
+ canvas.drawText(sogText, centerX - textBounds.width() / 2f, startY + lineHeight, sogPaint);
+
+ // COG
+ cogPaint.getTextBounds(cogText, 0, cogText.length(), textBounds);
+ canvas.drawText(cogText, centerX - textBounds.width() / 2f, startY + lineHeight * 2, cogPaint);
+ }
+
+ /**
+ * Определяет цвет для отображения координат
+ */
+ private Paint getCoordinatesPaint() {
+ if (vessel == null || vessel.getLatitude() == 0 || vessel.getLongitude() == 0) {
+ return errorPaint;
+ }
+
+ // Проверяем точность GPS
+ if (vessel.getAccuracy() > 0) {
+ if (vessel.getAccuracy() <= 5) {
+ return accentPaint; // Высокая точность - зеленый
+ } else if (vessel.getAccuracy() <= 20) {
+ return warningPaint; // Средняя точность - оранжевый
+ } else {
+ return errorPaint; // Низкая точность - красный
+ }
+ }
+
+ return textPaint; // По умолчанию - белый
+ }
+
+ /**
+ * Определяет цвет для отображения SOG
+ */
+ private Paint getSOGPaint() {
+ if (vessel == null || vessel.getSpeed() <= 0) {
+ return errorPaint;
+ }
+
+ return accentPaint; // Если есть данные о скорости - зеленый
+ }
+
+ /**
+ * Определяет цвет для отображения COG
+ */
+ private Paint getCOGPaint() {
+ if (vessel == null || vessel.getCourse() <= 0) {
+ return errorPaint;
+ }
+
+ return accentPaint; // Если есть данные о курсе - зеленый
+ }
+
+ /**
+ * Получает высоту виджета в dock-режиме
+ */
+ public int getDockHeight() {
+ if (isDocked()) {
+ return getHeight();
+ }
+ return (int) dp(DEFAULT_DOCK_HEIGHT_DP);
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 2f0bfcf..a325d7d 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -12,13 +12,15 @@
android:layout_height="match_parent" />
+
@@ -29,16 +31,28 @@
android:layout_height="wrap_content"
android:text="Центр на судне"
android:textSize="12sp"
- android:minWidth="100dp" />
+ android:minWidth="120dp"
+ android:background="@android:color/white"
+ android:layout_marginBottom="8dp" />
+ android:minWidth="120dp"
+ android:background="@android:color/white"
+ android:layout_marginBottom="8dp" />
+
+
@@ -53,6 +67,17 @@
android:layout_marginRight="0dp"
android:layout_marginBottom="0dp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/rawAssets/AisShipAssets.ai b/rawAssets/AisShipAssets.ai
new file mode 100644
index 0000000..43bd97b
--- /dev/null
+++ b/rawAssets/AisShipAssets.ai
@@ -0,0 +1,1157 @@
+%PDF-1.6
%
+1 0 obj
<>/OCGs[21 0 R 22 0 R 23 0 R 24 0 R]>>/Pages 3 0 R/Type/Catalog>>
endobj
2 0 obj
<>stream
+
+
+
+
+ application/pdf
+
+
+ ~ai-e0a2f4eb-2a93-4a6b-b967-c4914929b8c1_
+
+
+ Adobe Illustrator 28.7 (Windows)
+ 2025-09-03T10:47:15+04:00
+ 2025-09-03T10:47:16+03:00
+ 2025-09-03T10:47:16+03:00
+
+
+
+ 220
+ 256
+ JPEG
+ /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA
AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK
DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f
Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgBAADcAwER
AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA
AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB
UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE
1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ
qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy
obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp
0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo
+DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7
FXkH/OUX5gf4T/LG6tLaX09V8wE6dacftLEwrcyfRF8FexYYq+CsVdirsVdirsVdirsVdirsVfQX
/OHf5gfobztc+VLuXjYeYY+VqGPwre24LLSpoPUj5KfEhRir7SxV2KuxV2KuxV2KuxV2KuxV2Kux
V2KuxV2KuxV2KuxV8Gf85SfmB/iv8zrqztpeeleXgdOtQPsmZTW6kHzl+CvcIDir2D/nDn8uLe28
rX3nHUrdJJ9ZkNtpwkQNxtbdiHcV/wB+TAj/AGAxV9E/onSv+WKD/kUn9MVd+idK/wCWKD/kUn9M
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
V//Z
+
+
+
+ uuid:C1BCCE1871B8DB11993190FCD52B4E9F
+ xmp.did:2cf19409-0534-064d-84bc-a51733ce34de
+ uuid:ebd528f0-14e6-4ea6-a189-d42261123197
+ proof:pdf
+
+ uuid:f26375b8-4b94-4bf2-b25d-63f4cebe3acf
+ xmp.did:82f2829c-a590-594a-b5fe-65f28ff6f000
+ uuid:C1BCCE1871B8DB11993190FCD52B4E9F
+ proof:pdf
+
+
+
+
+ saved
+ xmp.iid:2cf19409-0534-064d-84bc-a51733ce34de
+ 2025-09-03T10:44:17+03:00
+ Adobe Illustrator 28.7 (Windows)
+ /
+
+
+
+ Document
+ Mobile
+ AIRobin
+ 1
+ False
+ False
+
+ 1080.000000
+ 1920.000000
+ Pixels
+
+
+
+ Cyan
+ Magenta
+ Yellow
+ Black
+
+
+
+
+
+ Группа образцов по умолчанию
+ 0
+
+
+
+ Белый
+ RGB
+ PROCESS
+ 255
+ 255
+ 255
+
+
+ Черный
+ RGB
+ PROCESS
+ 0
+ 0
+ 0
+
+
+ RGB красный
+ RGB
+ PROCESS
+ 255
+ 0
+ 0
+
+
+ RGB желтый
+ RGB
+ PROCESS
+ 255
+ 255
+ 0
+
+
+ RGB зеленый
+ RGB
+ PROCESS
+ 0
+ 255
+ 0
+
+
+ RGB голубой
+ RGB
+ PROCESS
+ 0
+ 255
+ 255
+
+
+ RGB синий
+ RGB
+ PROCESS
+ 0
+ 0
+ 255
+
+
+ RGB пурпурный
+ RGB
+ PROCESS
+ 255
+ 0
+ 255
+
+
+ R=193 G=39 B=45
+ RGB
+ PROCESS
+ 193
+ 39
+ 45
+
+
+ R=237 G=28 B=36
+ RGB
+ PROCESS
+ 237
+ 28
+ 36
+
+
+ R=241 G=90 B=36
+ RGB
+ PROCESS
+ 241
+ 90
+ 36
+
+
+ R=247 G=147 B=30
+ RGB
+ PROCESS
+ 247
+ 147
+ 30
+
+
+ R=251 G=176 B=59
+ RGB
+ PROCESS
+ 251
+ 176
+ 59
+
+
+ R=252 G=238 B=33
+ RGB
+ PROCESS
+ 252
+ 238
+ 33
+
+
+ R=217 G=224 B=33
+ RGB
+ PROCESS
+ 217
+ 224
+ 33
+
+
+ R=140 G=198 B=63
+ RGB
+ PROCESS
+ 140
+ 198
+ 63
+
+
+ R=57 G=181 B=74
+ RGB
+ PROCESS
+ 57
+ 181
+ 74
+
+
+ R=0 G=146 B=69
+ RGB
+ PROCESS
+ 0
+ 146
+ 69
+
+
+ R=0 G=104 B=55
+ RGB
+ PROCESS
+ 0
+ 104
+ 55
+
+
+ R=34 G=181 B=115
+ RGB
+ PROCESS
+ 34
+ 181
+ 115
+
+
+ R=0 G=169 B=157
+ RGB
+ PROCESS
+ 0
+ 169
+ 157
+
+
+ R=41 G=171 B=226
+ RGB
+ PROCESS
+ 41
+ 171
+ 226
+
+
+ R=0 G=113 B=188
+ RGB
+ PROCESS
+ 0
+ 113
+ 188
+
+
+ R=46 G=49 B=146
+ RGB
+ PROCESS
+ 46
+ 49
+ 146
+
+
+ R=27 G=20 B=100
+ RGB
+ PROCESS
+ 27
+ 20
+ 100
+
+
+ R=102 G=45 B=145
+ RGB
+ PROCESS
+ 102
+ 45
+ 145
+
+
+ R=147 G=39 B=143
+ RGB
+ PROCESS
+ 147
+ 39
+ 143
+
+
+ R=158 G=0 B=93
+ RGB
+ PROCESS
+ 158
+ 0
+ 93
+
+
+ R=212 G=20 B=90
+ RGB
+ PROCESS
+ 212
+ 20
+ 90
+
+
+ R=237 G=30 B=121
+ RGB
+ PROCESS
+ 237
+ 30
+ 121
+
+
+ R=199 G=178 B=153
+ RGB
+ PROCESS
+ 199
+ 178
+ 153
+
+
+ R=153 G=134 B=117
+ RGB
+ PROCESS
+ 153
+ 134
+ 117
+
+
+ R=115 G=99 B=87
+ RGB
+ PROCESS
+ 115
+ 99
+ 87
+
+
+ R=83 G=71 B=65
+ RGB
+ PROCESS
+ 83
+ 71
+ 65
+
+
+ R=198 G=156 B=109
+ RGB
+ PROCESS
+ 198
+ 156
+ 109
+
+
+ R=166 G=124 B=82
+ RGB
+ PROCESS
+ 166
+ 124
+ 82
+
+
+ R=140 G=98 B=57
+ RGB
+ PROCESS
+ 140
+ 98
+ 57
+
+
+ R=117 G=76 B=36
+ RGB
+ PROCESS
+ 117
+ 76
+ 36
+
+
+ R=96 G=56 B=19
+ RGB
+ PROCESS
+ 96
+ 56
+ 19
+
+
+ R=66 G=33 B=11
+ RGB
+ PROCESS
+ 66
+ 33
+ 11
+
+
+
+
+
+ Оттенки серого
+ 1
+
+
+
+ R=0 G=0 B=0
+ RGB
+ PROCESS
+ 0
+ 0
+ 0
+
+
+ R=26 G=26 B=26
+ RGB
+ PROCESS
+ 26
+ 26
+ 26
+
+
+ R=51 G=51 B=51
+ RGB
+ PROCESS
+ 51
+ 51
+ 51
+
+
+ R=77 G=77 B=77
+ RGB
+ PROCESS
+ 77
+ 77
+ 77
+
+
+ R=102 G=102 B=102
+ RGB
+ PROCESS
+ 102
+ 102
+ 102
+
+
+ R=128 G=128 B=128
+ RGB
+ PROCESS
+ 128
+ 128
+ 128
+
+
+ R=153 G=153 B=153
+ RGB
+ PROCESS
+ 153
+ 153
+ 153
+
+
+ R=179 G=179 B=179
+ RGB
+ PROCESS
+ 179
+ 179
+ 179
+
+
+ R=204 G=204 B=204
+ RGB
+ PROCESS
+ 204
+ 204
+ 204
+
+
+ R=230 G=230 B=230
+ RGB
+ PROCESS
+ 230
+ 230
+ 230
+
+
+ R=242 G=242 B=242
+ RGB
+ PROCESS
+ 242
+ 242
+ 242
+
+
+
+
+
+ Цветовые группы для мобильных устройств
+ 1
+
+
+
+ R=136 G=168 B=13
+ RGB
+ PROCESS
+ 136
+ 168
+ 13
+
+
+ R=127 G=71 B=221
+ RGB
+ PROCESS
+ 127
+ 71
+ 221
+
+
+ R=251 G=174 B=23
+ RGB
+ PROCESS
+ 251
+ 174
+ 23
+
+
+
+
+
+
+ Adobe PDF library 17.00
+ 21.0.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+endstream
endobj
3 0 obj
<>
endobj
5 0 obj
<>/ExtGState<>/Properties<>>>/Thumb 30 0 R/TrimBox[0.0 0.0 1080.0 1920.0]/Type/Page/PieceInfo<>>>
endobj
26 0 obj
<>stream
+HN0y
+@8NJAzÆ488馵tT)m;Y=wZw;0w}d
+Qfv(fwɇ ;SvrR °53CuܚӛyX
{]AJiG&)cN_o>+.U\POtpV