From 2f303134c1b09766f2b3a2cd808f1f9890544359 Mon Sep 17 00:00:00 2001 From: grigo Date: Mon, 15 Jun 2026 08:40:27 +0300 Subject: [PATCH] added linear slider --- README.md | 2 +- .../grigowashere/loratester/MainActivity.java | 1 + .../loratester/SettingsRepository.java | 25 +- .../loratester/ui/MapFragment.java | 188 +++++++--- app/src/main/res/color/map_tool_icon_tint.xml | 5 + app/src/main/res/drawable/bg_map_panel.xml | 6 + app/src/main/res/drawable/ic_center.xml | 10 + app/src/main/res/drawable/ic_heatmap.xml | 10 + app/src/main/res/drawable/ic_more_vert.xml | 10 + app/src/main/res/drawable/ic_paired_track.xml | 10 + app/src/main/res/drawable/ic_track.xml | 10 + app/src/main/res/layout/fragment_map.xml | 333 +++++++++++------- app/src/main/res/values/strings.xml | 6 + .../loratester/SettingsRepositoryTest.java | 24 ++ server/README.md | 4 +- .../__pycache__/elevation.cpython-313.pyc | Bin 22688 -> 22916 bytes server/core/elevation.py | 13 +- server/fastapi_app.py | 4 +- ...est_elevation.cpython-313-pytest-9.0.3.pyc | Bin 21009 -> 23554 bytes server/tests/test_elevation.py | 16 + 20 files changed, 481 insertions(+), 196 deletions(-) create mode 100644 app/src/main/res/color/map_tool_icon_tint.xml create mode 100644 app/src/main/res/drawable/bg_map_panel.xml create mode 100644 app/src/main/res/drawable/ic_center.xml create mode 100644 app/src/main/res/drawable/ic_heatmap.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_paired_track.xml create mode 100644 app/src/main/res/drawable/ic_track.xml create mode 100644 app/src/test/java/com/grigowashere/loratester/SettingsRepositoryTest.java diff --git a/README.md b/README.md index 416ffc5..4fe6c90 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Android-клиент и Python-сервер для мониторинга LoRa 1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py` 2. Соберите APK в Android Studio или `./gradlew assembleDebug` -3. В приложении: Настройки → URL `http://<ваш-сервер>:7634`, включите telnet при наличии моста COM→telnet +3. В приложении: Настройки → URL `https://lora.grigowashere.ru` (или свой сервер), включите telnet при наличии моста COM→telnet ## Тесты diff --git a/app/src/main/java/com/grigowashere/loratester/MainActivity.java b/app/src/main/java/com/grigowashere/loratester/MainActivity.java index 925d59b..a4e8ffa 100644 --- a/app/src/main/java/com/grigowashere/loratester/MainActivity.java +++ b/app/src/main/java/com/grigowashere/loratester/MainActivity.java @@ -49,6 +49,7 @@ public class MainActivity extends AppCompatActivity { ViewPager2 pager = findViewById(R.id.viewPager); TabLayout tabs = findViewById(R.id.tabLayout); + pager.setOffscreenPageLimit(1); pager.setAdapter(new MainPagerAdapter(this)); new TabLayoutMediator(tabs, pager, (tab, position) -> { int titleRes = switch (position) { diff --git a/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java index e4d2c10..9872e7e 100644 --- a/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java +++ b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java @@ -14,7 +14,8 @@ public class SettingsRepository { private static final String KEY_TELNET_ENABLED = "telnet_enabled"; private static final String KEY_DEVICE_ID = "device_id"; - public static final String DEFAULT_SERVER = "http://grigowashere.ru:7634"; + public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru"; + private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634"; public static final String DEFAULT_TELNET_HOST = "127.0.0.1"; public static final int DEFAULT_TELNET_PORT = 2727; public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)"; @@ -25,6 +26,28 @@ public class SettingsRepository { public SettingsRepository(Context context) { prefs = context.getApplicationContext() .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + migrateLegacyServerUrl(); + } + + private void migrateLegacyServerUrl() { + String current = prefs.getString(KEY_SERVER_URL, null); + if (current == null || !isLegacyServerUrl(current)) { + return; + } + prefs.edit().putString(KEY_SERVER_URL, DEFAULT_SERVER).apply(); + } + + static boolean isLegacyServerUrl(String url) { + if (url == null) { + return false; + } + String u = url.trim().toLowerCase(); + while (u.endsWith("/")) { + u = u.substring(0, u.length() - 1); + } + return u.equals(LEGACY_SERVER_HTTP) + || u.equals("http://grigowashere.ru") + || u.equals("https://grigowashere.ru:7634"); } public String getServerUrl() { diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index dfa3ad2..520f552 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -7,9 +7,10 @@ import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.view.View; import android.widget.AdapterView; import android.widget.ArrayAdapter; -import android.widget.Button; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; @@ -20,6 +21,8 @@ import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import com.google.android.material.button.MaterialButtonToggleGroup; + import com.grigowashere.loratester.CommandPoller; import com.grigowashere.loratester.LoraApp; import com.grigowashere.loratester.PeerDevices; @@ -87,7 +90,7 @@ public class MapFragment extends Fragment { private static final long LORA_STATS_FRESH_MS = 120_000; private static final int HEATMAP_GPS_FOLLOW_M = 50; private static final long HEATMAP_GPS_DEBOUNCE_MS = 2000L; - private static final int[] HEATMAP_RADIUS_OPTIONS = {100, 200, 500}; + private static final int[] HEATMAP_RADIUS_OPTIONS = {50, 100, 200, 500}; private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final DateFormat timeFormat = @@ -109,14 +112,14 @@ public class MapFragment extends Fragment { private TextView trackStatus; private ImageView iconServer; private ImageView iconLora; - private Button btnTrack; - private Button btnPairedTrack; - private Button btnFindHill; - private Button btnHeatmap; - private Button btnCenterMe; - private Button btnCenterTx; - private Button btnCenterRx; - private Button btnCenterBoth; + private ImageButton btnTrack; + private ImageButton btnPairedTrack; + private ImageButton btnFindHill; + private ImageButton btnHeatmap; + private ImageButton btnToolCenter; + private ImageButton btnToolMore; + private View mapToolDrawer; + private MaterialButtonToggleGroup mapCenterMode; private Spinner trackSpinner; private Spinner mapHeatmapRadius; private TextView mapHeatmapStatus; @@ -142,11 +145,13 @@ public class MapFragment extends Fragment { private Polyline hillPathLine; private ElevationHeatmapLayer heatmapLayer; private boolean heatmapActive; - private int heatmapRadiusM = 200; + private int heatmapRadiusM = 100; private double lastHeatmapLat = Double.NaN; private double lastHeatmapLon = Double.NaN; private boolean suppressHeatmapSpinner; private Runnable heatmapReloadRunnable; + private boolean suppressCenterToggle; + private boolean mapGestureActive; private float touchDownX; private float touchDownY; private Runnable pendingFitRunnable; @@ -185,28 +190,19 @@ public class MapFragment extends Fragment { btnPairedTrack = view.findViewById(R.id.btnPairedTrack); btnFindHill = view.findViewById(R.id.btnFindHill); btnHeatmap = view.findViewById(R.id.btnHeatmap); + btnToolCenter = view.findViewById(R.id.btnToolCenter); + btnToolMore = view.findViewById(R.id.btnToolMore); + mapToolDrawer = view.findViewById(R.id.mapToolDrawer); + mapCenterMode = view.findViewById(R.id.mapCenterMode); mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius); mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus); mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend); - btnCenterMe = view.findViewById(R.id.btnCenterMe); - btnCenterTx = view.findViewById(R.id.btnCenterTx); - btnCenterRx = view.findViewById(R.id.btnCenterRx); - btnCenterBoth = view.findViewById(R.id.btnCenterBoth); trackSpinner = view.findViewById(R.id.trackSpinner); pollHelper = new FragmentPollHelper(this, this::refreshDevices); - if (btnCenterMe != null) { - btnCenterMe.setOnClickListener(v -> centerOnSelf()); - } - if (btnCenterTx != null) { - btnCenterTx.setOnClickListener(v -> centerOnRole(StatsExtractor.ROLE_TX)); - } - if (btnCenterRx != null) { - btnCenterRx.setOnClickListener(v -> centerOnRole(StatsExtractor.ROLE_RX)); - } - if (btnCenterBoth != null) { - btnCenterBoth.setOnClickListener(v -> centerOnBoth()); - } + setupToolRail(); + setupCenterModeToggle(); + if (btnFindHill != null) { btnFindHill.setOnClickListener(v -> findNearestHill()); } @@ -234,6 +230,67 @@ public class MapFragment extends Fragment { setupTrackSpinnerListener(); } + private void setupToolRail() { + if (btnToolCenter != null) { + btnToolCenter.setOnClickListener(v -> { + setMapDrawerOpen(true); + if (mapCenterMode != null) { + mapCenterMode.requestFocus(); + } + }); + } + if (btnToolMore != null) { + btnToolMore.setOnClickListener(v -> setMapDrawerOpen( + mapToolDrawer == null || mapToolDrawer.getVisibility() != View.VISIBLE)); + } + } + + private void setMapDrawerOpen(boolean open) { + if (mapToolDrawer != null) { + mapToolDrawer.setVisibility(open ? View.VISIBLE : View.GONE); + } + if (btnToolMore != null) { + btnToolMore.setActivated(open); + } + } + + private void setupCenterModeToggle() { + if (mapCenterMode == null) { + return; + } + mapCenterMode.addOnButtonCheckedListener((group, checkedId, isChecked) -> { + if (suppressCenterToggle || !isChecked) { + return; + } + boolean ok; + if (checkedId == R.id.centerMe) { + ok = centerOnSelf(); + } else if (checkedId == R.id.centerTx) { + ok = centerOnRole(StatsExtractor.ROLE_TX); + } else if (checkedId == R.id.centerRx) { + ok = centerOnRole(StatsExtractor.ROLE_RX); + } else if (checkedId == R.id.centerBoth) { + ok = centerOnBoth(); + } else { + ok = true; + } + if (!ok) { + Toast.makeText(requireContext(), R.string.map_center_unavailable, Toast.LENGTH_SHORT) + .show(); + clearCenterModeSelection(); + } + }); + } + + private void clearCenterModeSelection() { + if (mapCenterMode == null) { + return; + } + suppressCenterToggle = true; + mapCenterMode.clearChecked(); + suppressCenterToggle = false; + } + private void setupTrackSpinnerListener() { trackSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override @@ -252,12 +309,13 @@ public class MapFragment extends Fragment { private void setupMapView() { mapView.setClickable(true); - mapView.getMapScaleBar().setVisible(true); + mapView.getMapScaleBar().setVisible(false); mapView.setBuiltInZoomControls(false); mapView.setOnTouchListener((v, event) -> { int action = event.getActionMasked(); if (action == MotionEvent.ACTION_DOWN) { + mapGestureActive = true; touchDownX = event.getX(); touchDownY = event.getY(); v.getParent().requestDisallowInterceptTouchEvent(true); @@ -270,9 +328,11 @@ public class MapFragment extends Fragment { } } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { v.getParent().requestDisallowInterceptTouchEvent(false); + mapGestureActive = false; if (userMovedMap) { saveCameraState(); } + requestMapInvalidate(); } return false; }); @@ -282,7 +342,7 @@ public class MapFragment extends Fragment { requireContext(), "loratester-tiles", TILE_SIZE_PX, - 1f, + 2.5f, mapView.getModel().frameBufferModel.getOverdrawFactor() ); @@ -384,6 +444,10 @@ public class MapFragment extends Fragment { iconLora = null; btnFindHill = null; btnHeatmap = null; + btnToolCenter = null; + btnToolMore = null; + mapToolDrawer = null; + mapCenterMode = null; mapHeatmapRadius = null; mapHeatmapStatus = null; mapHeatmapLegend = null; @@ -393,11 +457,8 @@ public class MapFragment extends Fragment { mapStatus = null; mapDistance = null; trackStatus = null; - btnCenterMe = null; - btnCenterTx = null; - btnCenterRx = null; - btnCenterBoth = null; btnTrack = null; + btnPairedTrack = null; trackSpinner = null; pollHelper = null; super.onDestroyView(); @@ -434,7 +495,9 @@ public class MapFragment extends Fragment { if (!isAdded() || btnTrack == null) { return; } - btnTrack.setText(recording ? R.string.track_stop : R.string.track_start); + btnTrack.setActivated(recording); + btnTrack.setContentDescription(getString( + recording ? R.string.track_stop : R.string.track_start)); if (trackStatus != null) { trackStatus.setText(getString(R.string.track_status, pointCount)); } @@ -494,9 +557,27 @@ public class MapFragment extends Fragment { } else { liveTrackMarker.setLatLong(pos); } + requestMapInvalidate(); + } + + private void requestMapInvalidate() { + if (!isMapReady() || mapGestureActive) { + return; + } mapView.invalidate(); } + private void requestMapInvalidateAfterLayout() { + if (!isMapReady()) { + return; + } + mapView.post(() -> { + if (!mapGestureActive) { + mapView.invalidate(); + } + }); + } + private void clearLiveTrackLayers() { liveTrackPoints.clear(); if (mapView != null) { @@ -889,7 +970,7 @@ public class MapFragment extends Fragment { bounds.add(new LatLong(fromLat, fromLon)); bounds.add(hillPos); fitBoundsOnce(bounds, false, true); - mapView.invalidate(); + requestMapInvalidateAfterLayout(); } private void clearHillLayers() { @@ -987,13 +1068,16 @@ public class MapFragment extends Fragment { /** Finer step for small radius, coarser for large (meters). */ private static int heatmapStepForRadius(int radiusM) { + if (radiusM <= 50) { + return 1; + } if (radiusM <= 100) { - return 8; + return 1; } if (radiusM <= 200) { - return 12; + return 3; } - return 18; + return 6; } private void loadHeatmap(double lat, double lon, int radiusM) { @@ -1044,7 +1128,7 @@ public class MapFragment extends Fragment { )); } if (mapView != null) { - mapView.invalidate(); + requestMapInvalidate(); } }); } catch (Exception e) { @@ -1208,39 +1292,43 @@ public class MapFragment extends Fragment { } } - private void centerOnSelf() { + private boolean centerOnSelf() { if (uploader == null) { - return; + return false; } String myId = uploader.getDeviceId(); for (DeviceInfo d : lastDevices) { if (myId.equals(d.device_id) && GeoUtils.isValidCoordinate(d.lat, d.lon)) { centerOnPoint(new LatLong(d.lat, d.lon), (byte) 14); - return; + return true; } } + return false; } - private void centerOnRole(String role) { + private boolean centerOnRole(String role) { for (DeviceInfo d : lastDevices) { if (role.equals(d.role) && GeoUtils.isValidCoordinate(d.lat, d.lon)) { centerOnPoint(new LatLong(d.lat, d.lon), (byte) 14); - return; + return true; } } + return false; } - private void centerOnBoth() { + private boolean centerOnBoth() { List points = new ArrayList<>(); for (DeviceInfo d : lastDevices) { if (GeoUtils.isValidCoordinate(d.lat, d.lon)) { points.add(new LatLong(d.lat, d.lon)); } } - if (!points.isEmpty()) { - userMovedMap = false; - fitBoundsOnce(points, points.size() == 1, true); + if (points.isEmpty()) { + return false; } + userMovedMap = false; + fitBoundsOnce(points, points.size() == 1, true); + return true; } private void centerOnPoint(LatLong point, byte zoom) { @@ -1251,7 +1339,7 @@ public class MapFragment extends Fragment { position.setCenter(point); position.setZoomLevel(zoom); saveCameraState(); - mapView.invalidate(); + requestMapInvalidateAfterLayout(); } private void saveCameraState() { @@ -1307,6 +1395,7 @@ public class MapFragment extends Fragment { position.setCenter(points.get(0)); position.setZoomLevel((byte) 14); saveCameraState(); + requestMapInvalidateAfterLayout(); return; } double minLat = Double.MAX_VALUE; @@ -1333,5 +1422,6 @@ public class MapFragment extends Fragment { byte zoom = (byte) Math.max(12, Math.min(16, Math.floor(Math.min(latZoom, lonZoom)))); position.setZoomLevel(zoom); saveCameraState(); + requestMapInvalidateAfterLayout(); } } diff --git a/app/src/main/res/color/map_tool_icon_tint.xml b/app/src/main/res/color/map_tool_icon_tint.xml new file mode 100644 index 0000000..0f9ea6a --- /dev/null +++ b/app/src/main/res/color/map_tool_icon_tint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_map_panel.xml b/app/src/main/res/drawable/bg_map_panel.xml new file mode 100644 index 0000000..96512da --- /dev/null +++ b/app/src/main/res/drawable/bg_map_panel.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_center.xml b/app/src/main/res/drawable/ic_center.xml new file mode 100644 index 0000000..dcfa3fa --- /dev/null +++ b/app/src/main/res/drawable/ic_center.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_heatmap.xml b/app/src/main/res/drawable/ic_heatmap.xml new file mode 100644 index 0000000..1ef92db --- /dev/null +++ b/app/src/main/res/drawable/ic_heatmap.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..2707778 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_paired_track.xml b/app/src/main/res/drawable/ic_paired_track.xml new file mode 100644 index 0000000..a5c517a --- /dev/null +++ b/app/src/main/res/drawable/ic_paired_track.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_track.xml b/app/src/main/res/drawable/ic_track.xml new file mode 100644 index 0000000..df7129f --- /dev/null +++ b/app/src/main/res/drawable/ic_track.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 6bac69e..8457162 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -1,5 +1,6 @@ @@ -8,175 +9,232 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - + android:layout_marginEnd="56dp" + android:background="@drawable/bg_map_panel" + android:elevation="6dp" + android:orientation="vertical" + android:paddingStart="8dp" + android:paddingTop="6dp" + android:paddingEnd="8dp" + android:paddingBottom="6dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_marginTop="8dp" + android:layout_marginEnd="56dp" + android:background="@drawable/bg_map_panel" + android:elevation="6dp" + android:scrollbars="none" + android:visibility="gone"> - - - - - - - - - - - + android:padding="8dp"> - - + android:textSize="10sp" + android:textStyle="bold" /> - + android:orientation="vertical" + app:selectionRequired="false" + app:singleSelection="true"> -