Initial commit: Компас Цель — Android приложение для навигации по GPS
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk.idsig
|
||||||
74
README.md
Normal file
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Компас Цель (Compass Target)
|
||||||
|
|
||||||
|
Android-приложение на Kotlin для определения направления и расстояния до цели по GPS.
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Приложение показывает на карте-компасе направление, расстояние и высоту до выбранной цели (репитер, метка, точка сбора). Интерфейс в тёмной теме, всё обновляется в реальном времени.
|
||||||
|
|
||||||
|
**Координаты цели (по умолчанию):** 67.370898, 32.489668
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- 📍 **Дистанция до цели** — метры/километры, обновление в реальном времени
|
||||||
|
- 🧭 **Компас с фиксированным севером** — красная стрелка показывает азимут на цель, белый треугольник — ориентацию телефона
|
||||||
|
- 🛰️ **Спутники** — счётчик и разбивка по созвездиям: GPS, ГЛОНАСС, Galileo, BeiDou
|
||||||
|
- 📏 **Азимут** — в градусах и словесно (С, СВ, В, ЮВ, Ю, ЮЗ, З, СЗ)
|
||||||
|
- ⛰️ **Высота над уровнем моря** и точность GPS (± м)
|
||||||
|
- 🎯 **10 редактируемых целей** — название, широта, долгота; сохранение между запусками
|
||||||
|
- 📍 **Вставка текущих координат** в поле цели одной кнопкой
|
||||||
|
- 🌙 Тёмная тема, экран не гаснет
|
||||||
|
|
||||||
|
## Цели по умолчанию
|
||||||
|
|
||||||
|
| № | Название | Широта | Долгота |
|
||||||
|
|---|----------|--------|---------|
|
||||||
|
| 1 | П-Зори DMR | 67.371 | 32.48967 |
|
||||||
|
| 2 | R1ZBF-SVX | 67.3685 | 32.49 |
|
||||||
|
| 3 | Лысая Гора | 67.43083 | 32.4535 |
|
||||||
|
| 4 | Апатиты DMR | 67.55917 | 33.40967 |
|
||||||
|
| 5 | Кировск DMR | 67.602 | 33.72767 |
|
||||||
|
| 6 | Мончегорск DMR | 67.93883 | 32.9375 |
|
||||||
|
| 7 | R1ZCU-SVX | 69.00283 | 33.102 |
|
||||||
|
| 8 | R1ZAAG-HE | 69.0735 | 33.43167 |
|
||||||
|
|
||||||
|
## Как пользоваться
|
||||||
|
|
||||||
|
1. **Установите APK** из релизов → `app-debug.apk`
|
||||||
|
2. Разрешите доступ к геолокации (GPS)
|
||||||
|
3. Тап по названию цели сверху — выбор из 10 целей
|
||||||
|
4. Кнопка **«Редактировать…»** — изменить цель или вставить текущие координаты
|
||||||
|
5. Следуйте за красной стрелкой компаса
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Требования: JDK 17+, Android SDK (platform 34), Gradle 9.2+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "sdk.dir=/путь/к/Android/Sdk" > local.properties
|
||||||
|
gradle :app:assembleDebug --no-daemon
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Ресурсы сборки ограничены в `gradle.properties` (под слабые ПК).
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/com/ua1zbe/compass/
|
||||||
|
├── MainActivity.kt — GPS, компас, сенсоры, меню и редактирование целей
|
||||||
|
├── CompassView.kt — отрисовка компаса (Canvas)
|
||||||
|
└── TargetsStore.kt — хранение целей (SharedPreferences/JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Компас использует магнитометр — при неточных показаниях калибруйте телефон «восьмёркой»
|
||||||
|
- Высота берётся из GPS
|
||||||
|
- ГЛОНАСС и другие созвездия определяются через `GnssStatus` (Android 7+)
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
GPL-3.0
|
||||||
36
app/build.gradle.kts
Normal file
36
app/build.gradle.kts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.ua1zbe.compass"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.ua1zbe.compass"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jetbrains.kotlin:kotlin-stdlib")
|
||||||
|
}
|
||||||
25
app/src/main/AndroidManifest.xml
Normal file
25
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Compass">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
169
app/src/main/java/com/ua1zbe/compass/CompassView.kt
Normal file
169
app/src/main/java/com/ua1zbe/compass/CompassView.kt
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package com.ua1zbe.compass
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
class CompassView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : View(context, attrs) {
|
||||||
|
|
||||||
|
var heading = 0f
|
||||||
|
var bearing = 0f
|
||||||
|
var hasFix = false
|
||||||
|
|
||||||
|
private val dialPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val tickPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val majorTickPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val needlePaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val needleShadowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val centerPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val headingPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
private val needlePath = Path()
|
||||||
|
private val shadowPath = Path()
|
||||||
|
private val headingPath = Path()
|
||||||
|
|
||||||
|
private val cardinals = arrayOf("N", "E", "S", "W")
|
||||||
|
|
||||||
|
init {
|
||||||
|
dialPaint.color = Color.rgb(18, 28, 48)
|
||||||
|
dialPaint.style = Paint.Style.FILL
|
||||||
|
|
||||||
|
ringPaint.color = Color.rgb(41, 224, 255)
|
||||||
|
ringPaint.style = Paint.Style.STROKE
|
||||||
|
ringPaint.strokeWidth = 2f * resources.displayMetrics.density
|
||||||
|
|
||||||
|
tickPaint.color = Color.rgb(90, 110, 140)
|
||||||
|
tickPaint.strokeWidth = 1f * resources.displayMetrics.density
|
||||||
|
|
||||||
|
majorTickPaint.color = Color.rgb(41, 224, 255)
|
||||||
|
majorTickPaint.strokeWidth = 2f * resources.displayMetrics.density
|
||||||
|
|
||||||
|
textPaint.color = Color.rgb(138, 151, 168)
|
||||||
|
textPaint.textAlign = Paint.Align.CENTER
|
||||||
|
textPaint.textSize = 14f * resources.displayMetrics.density
|
||||||
|
textPaint.isFakeBoldText = true
|
||||||
|
|
||||||
|
needlePaint.color = Color.rgb(255, 90, 95)
|
||||||
|
needlePaint.style = Paint.Style.FILL
|
||||||
|
|
||||||
|
needleShadowPaint.color = Color.rgb(0, 0, 0)
|
||||||
|
needleShadowPaint.style = Paint.Style.FILL
|
||||||
|
|
||||||
|
centerPaint.color = Color.rgb(41, 224, 255)
|
||||||
|
centerPaint.style = Paint.Style.FILL
|
||||||
|
|
||||||
|
headingPaint.color = Color.rgb(233, 238, 247)
|
||||||
|
headingPaint.style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val w = width.toFloat()
|
||||||
|
val h = height.toFloat()
|
||||||
|
val cx = w / 2f
|
||||||
|
val cy = h / 2f
|
||||||
|
val radius = min(w, h) / 2f - 24f * resources.displayMetrics.density
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
|
||||||
|
// dial background with subtle glow (static, north at top)
|
||||||
|
val glow = RadialGradient(cx, cy, radius, Color.rgb(30, 45, 75), Color.rgb(18, 28, 48), Shader.TileMode.CLAMP)
|
||||||
|
dialPaint.shader = glow
|
||||||
|
canvas.drawCircle(cx, cy, radius, dialPaint)
|
||||||
|
dialPaint.shader = null
|
||||||
|
|
||||||
|
// outer ring
|
||||||
|
canvas.drawCircle(cx, cy, radius, ringPaint)
|
||||||
|
|
||||||
|
// ticks: fixed, north at top
|
||||||
|
for (deg in 0 until 360 step 5) {
|
||||||
|
val angle = Math.toRadians((deg - 90).toDouble())
|
||||||
|
val cosA = cos(angle).toFloat()
|
||||||
|
val sinA = sin(angle).toFloat()
|
||||||
|
val isMajor = deg % 30 == 0
|
||||||
|
val outer = radius - 10f * density
|
||||||
|
val inner = if (isMajor) radius - 26f * density else radius - 18f * density
|
||||||
|
canvas.drawLine(
|
||||||
|
cx + outer * cosA, cy + outer * sinA,
|
||||||
|
cx + inner * cosA, cy + inner * sinA,
|
||||||
|
if (isMajor) majorTickPaint else tickPaint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cardinal letters: N top (red), E right, S bottom, W left
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val angle = Math.toRadians((i * 90 - 90).toDouble())
|
||||||
|
val cosA = cos(angle).toFloat()
|
||||||
|
val sinA = sin(angle).toFloat()
|
||||||
|
val r = radius - 42f * density
|
||||||
|
if (i == 0) {
|
||||||
|
textPaint.color = Color.rgb(255, 90, 95)
|
||||||
|
textPaint.textSize = 20f * density
|
||||||
|
canvas.drawText(cardinals[i], cx + r * cosA, cy + r * sinA + 7f * density, textPaint)
|
||||||
|
textPaint.textSize = 14f * density
|
||||||
|
} else {
|
||||||
|
textPaint.color = Color.rgb(138, 151, 168)
|
||||||
|
canvas.drawText(cardinals[i], cx + r * cosA, cy + r * sinA + 5f * density, textPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// device heading marker (white triangle), rotated by heading
|
||||||
|
headingPath.reset()
|
||||||
|
val hr = radius - 58f * density
|
||||||
|
headingPath.moveTo(cx, cy - hr - 8f * density)
|
||||||
|
headingPath.lineTo(cx + 8f * density, cy - hr + 6f * density)
|
||||||
|
headingPath.lineTo(cx - 8f * density, cy - hr + 6f * density)
|
||||||
|
headingPath.close()
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(heading, cx, cy)
|
||||||
|
canvas.drawPath(headingPath, headingPaint)
|
||||||
|
canvas.restore()
|
||||||
|
|
||||||
|
// needle pointing to target bearing (absolute, north at top = 0)
|
||||||
|
if (hasFix) {
|
||||||
|
needlePath.reset()
|
||||||
|
shadowPath.reset()
|
||||||
|
val needleLen = radius - 74f * density
|
||||||
|
val needleBase = radius - 95f * density
|
||||||
|
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(bearing, cx, cy)
|
||||||
|
needlePath.moveTo(cx, cy - needleLen)
|
||||||
|
needlePath.lineTo(cx + 9f * density, cy - needleBase)
|
||||||
|
needlePath.lineTo(cx - 9f * density, cy - needleBase)
|
||||||
|
needlePath.close()
|
||||||
|
|
||||||
|
shadowPath.moveTo(cx, cy - needleLen + 4f * density)
|
||||||
|
shadowPath.lineTo(cx + 9f * density, cy - needleBase + 4f * density)
|
||||||
|
shadowPath.lineTo(cx - 9f * density, cy - needleBase + 4f * density)
|
||||||
|
shadowPath.close()
|
||||||
|
|
||||||
|
needleShadowPaint.alpha = 100
|
||||||
|
canvas.drawPath(shadowPath, needleShadowPaint)
|
||||||
|
needleShadowPaint.alpha = 255
|
||||||
|
canvas.drawPath(needlePath, needlePaint)
|
||||||
|
|
||||||
|
canvas.drawCircle(cx, cy, 10f * density, centerPaint)
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setData(heading: Float, bearing: Float, hasFix: Boolean) {
|
||||||
|
this.heading = heading
|
||||||
|
this.bearing = bearing
|
||||||
|
this.hasFix = hasFix
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
394
app/src/main/java/com/ua1zbe/compass/MainActivity.kt
Normal file
394
app/src/main/java/com/ua1zbe/compass/MainActivity.kt
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
package com.ua1zbe.compass
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.location.GnssStatus
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class MainActivity : Activity(), SensorEventListener, LocationListener {
|
||||||
|
|
||||||
|
private lateinit var sensorManager: SensorManager
|
||||||
|
private lateinit var locationManager: LocationManager
|
||||||
|
private lateinit var compass: CompassView
|
||||||
|
private lateinit var tvTitle: TextView
|
||||||
|
private lateinit var tvDistance: TextView
|
||||||
|
private lateinit var tvUnit: TextView
|
||||||
|
private lateinit var tvBearing: TextView
|
||||||
|
private lateinit var tvAltitude: TextView
|
||||||
|
private lateinit var tvAccuracy: TextView
|
||||||
|
private lateinit var tvSatellites: TextView
|
||||||
|
private lateinit var tvStatus: TextView
|
||||||
|
|
||||||
|
private var lastGravity = FloatArray(3)
|
||||||
|
private var lastGeo = FloatArray(3)
|
||||||
|
private var heading = 0f
|
||||||
|
private var bearing = 0f
|
||||||
|
private var hasFix = false
|
||||||
|
private var lastLocation: Location? = null
|
||||||
|
|
||||||
|
private var targets: MutableList<Target> = mutableListOf()
|
||||||
|
private var activeTarget: Target = TargetsStore.defaultTargets[0]
|
||||||
|
|
||||||
|
private var satellitesUsed = 0
|
||||||
|
private var satellitesGps = 0
|
||||||
|
private var satellitesGlonass = 0
|
||||||
|
private var satellitesGalileo = 0
|
||||||
|
private var satellitesBeidou = 0
|
||||||
|
private var satellitesOther = 0
|
||||||
|
|
||||||
|
private val gnssCallback = object : GnssStatus.Callback() {
|
||||||
|
override fun onSatelliteStatusChanged(status: GnssStatus) {
|
||||||
|
var used = 0
|
||||||
|
var gps = 0
|
||||||
|
var glonass = 0
|
||||||
|
var galileo = 0
|
||||||
|
var beidou = 0
|
||||||
|
var other = 0
|
||||||
|
for (i in 0 until status.satelliteCount) {
|
||||||
|
when (status.getConstellationType(i)) {
|
||||||
|
GnssStatus.CONSTELLATION_GPS -> gps++
|
||||||
|
GnssStatus.CONSTELLATION_GLONASS -> glonass++
|
||||||
|
GnssStatus.CONSTELLATION_GALILEO -> galileo++
|
||||||
|
GnssStatus.CONSTELLATION_BEIDOU -> beidou++
|
||||||
|
else -> other++
|
||||||
|
}
|
||||||
|
if (status.usedInFix(i)) used++
|
||||||
|
}
|
||||||
|
satellitesUsed = used
|
||||||
|
satellitesGps = gps
|
||||||
|
satellitesGlonass = glonass
|
||||||
|
satellitesGalileo = galileo
|
||||||
|
satellitesBeidou = beidou
|
||||||
|
satellitesOther = other
|
||||||
|
runOnUiThread { updateSatellites() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
|
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
|
||||||
|
tvTitle = findViewById(R.id.tvTitle)
|
||||||
|
tvDistance = findViewById(R.id.tvDistance)
|
||||||
|
tvUnit = findViewById(R.id.tvUnit)
|
||||||
|
tvBearing = findViewById(R.id.tvBearing)
|
||||||
|
tvAltitude = findViewById(R.id.tvAltitude)
|
||||||
|
tvAccuracy = findViewById(R.id.tvAccuracy)
|
||||||
|
tvSatellites = findViewById(R.id.tvSatellites)
|
||||||
|
tvStatus = findViewById(R.id.tvStatus)
|
||||||
|
compass = findViewById(R.id.compass)
|
||||||
|
|
||||||
|
targets = TargetsStore.load(this)
|
||||||
|
val activeIndex = TargetsStore.getActiveIndex(this)
|
||||||
|
activeTarget = targets[activeIndex]
|
||||||
|
updateTitle()
|
||||||
|
|
||||||
|
tvTitle.setOnClickListener { showTargetsMenu() }
|
||||||
|
|
||||||
|
checkPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPermission(): Boolean {
|
||||||
|
return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPermissions() {
|
||||||
|
if (hasPermission()) {
|
||||||
|
startSensors()
|
||||||
|
} else if (Build.VERSION.SDK_INT >= 23) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == 1) {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
startSensors()
|
||||||
|
} else {
|
||||||
|
tvStatus.text = "Нет разрешения на доступ к GPS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSensors() {
|
||||||
|
try {
|
||||||
|
locationManager.requestLocationUpdates(
|
||||||
|
LocationManager.GPS_PROVIDER, 1000L, 1f, this
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tvStatus.text = "GPS недоступен: ${e.message}"
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= 24) {
|
||||||
|
locationManager.registerGnssStatusCallback(gnssCallback)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tvStatus.text = "Спутники недоступны: ${e.message}"
|
||||||
|
}
|
||||||
|
sensorManager.registerListener(
|
||||||
|
this,
|
||||||
|
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER),
|
||||||
|
SensorManager.SENSOR_DELAY_UI
|
||||||
|
)
|
||||||
|
sensorManager.registerListener(
|
||||||
|
this,
|
||||||
|
sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD),
|
||||||
|
SensorManager.SENSOR_DELAY_UI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Цели ----------
|
||||||
|
|
||||||
|
private fun updateTitle() {
|
||||||
|
tvTitle.text = "ЦЕЛЬ: ${activeTarget.name} ▾"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshTargetData() {
|
||||||
|
val loc = lastLocation ?: return
|
||||||
|
val target = Location("target").apply {
|
||||||
|
latitude = activeTarget.latitude
|
||||||
|
longitude = activeTarget.longitude
|
||||||
|
}
|
||||||
|
val dist = loc.distanceTo(target)
|
||||||
|
bearing = (loc.bearingTo(target) + 360f) % 360f
|
||||||
|
|
||||||
|
compass.setData(heading, bearing, hasFix)
|
||||||
|
|
||||||
|
tvDistance.text = formatDistance(dist)
|
||||||
|
tvUnit.text = if (dist >= 1000f) "км до цели" else "метров до цели"
|
||||||
|
tvBearing.text = "${bearing.roundToInt()}° ${cardinal(bearing)}"
|
||||||
|
tvStatus.text = "GPS: ${loc.latitude.toString().take(8)}, ${loc.longitude.toString().take(8)} · Цель: ${activeTarget.latitude.toString().take(8)}, ${activeTarget.longitude.toString().take(8)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showTargetsMenu() {
|
||||||
|
val names = targets.mapIndexed { i, t ->
|
||||||
|
"${i + 1}. ${t.name} — ${t.latitude.toString().take(9)}, ${t.longitude.toString().take(9)}"
|
||||||
|
}.toTypedArray()
|
||||||
|
val active = targets.indexOf(activeTarget)
|
||||||
|
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Выбор цели")
|
||||||
|
.setSingleChoiceItems(names, active) { dialog, which ->
|
||||||
|
activeTarget = targets[which]
|
||||||
|
TargetsStore.setActiveIndex(this, which)
|
||||||
|
updateTitle()
|
||||||
|
refreshTargetData()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Отмена", null)
|
||||||
|
.setNeutralButton("Редактировать…") { _, _ ->
|
||||||
|
showEditDialog(active)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showEditDialog(index: Int) {
|
||||||
|
val target = targets[index]
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
val pad = (16 * density).toInt()
|
||||||
|
|
||||||
|
val container = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun makeLabel(text: String) = TextView(this).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(0xFF8A97A8.toInt())
|
||||||
|
setPadding(0, (8 * density).toInt(), 0, (4 * density).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
val etName = EditText(this).apply { setText(target.name) }
|
||||||
|
val etLat = EditText(this).apply {
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL or android.text.InputType.TYPE_NUMBER_FLAG_SIGNED
|
||||||
|
setText(target.latitude.toString())
|
||||||
|
}
|
||||||
|
val etLon = EditText(this).apply {
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL or android.text.InputType.TYPE_NUMBER_FLAG_SIGNED
|
||||||
|
setText(target.longitude.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addView(makeLabel("Название"))
|
||||||
|
container.addView(etName)
|
||||||
|
container.addView(makeLabel("Широта"))
|
||||||
|
container.addView(etLat)
|
||||||
|
container.addView(makeLabel("Долгота"))
|
||||||
|
container.addView(etLon)
|
||||||
|
|
||||||
|
val btnInsert = Button(this).apply {
|
||||||
|
text = "Вставить текущие координаты"
|
||||||
|
isAllCaps = false
|
||||||
|
setOnClickListener {
|
||||||
|
val loc = lastLocation ?: run {
|
||||||
|
tvStatus.text = "Нет GPS-фикса для вставки координат"
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
etLat.setText(loc.latitude.toString())
|
||||||
|
etLon.setText(loc.longitude.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val lp = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { topMargin = (12 * density).toInt() }
|
||||||
|
container.addView(btnInsert, lp)
|
||||||
|
|
||||||
|
val builder = AlertDialog.Builder(this)
|
||||||
|
.setTitle("Редактирование цели ${index + 1}")
|
||||||
|
.setView(container)
|
||||||
|
.setNegativeButton("Отмена", null)
|
||||||
|
.setPositiveButton("Сохранить") { _, _ ->
|
||||||
|
val lat = etLat.text.toString().toDoubleOrNull()
|
||||||
|
val lon = etLon.text.toString().toDoubleOrNull()
|
||||||
|
if (lat == null || lon == null || lat !in -90.0..90.0 || lon !in -180.0..180.0) {
|
||||||
|
tvStatus.text = "Некорректные координаты"
|
||||||
|
return@setPositiveButton
|
||||||
|
}
|
||||||
|
targets[index] = Target(etName.text.toString().ifBlank { "Цель ${index + 1}" }, lat, lon)
|
||||||
|
TargetsStore.save(this, targets)
|
||||||
|
if (targets[index] === activeTarget) {
|
||||||
|
activeTarget = targets[index]
|
||||||
|
updateTitle()
|
||||||
|
refreshTargetData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Сенсоры / GPS ----------
|
||||||
|
|
||||||
|
private fun updateSatellites() {
|
||||||
|
if (satellitesUsed == 0) {
|
||||||
|
tvSatellites.text = "—"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val parts = mutableListOf("GPS $satellitesGps")
|
||||||
|
if (satellitesGlonass > 0) parts.add("ГЛОНАСС $satellitesGlonass")
|
||||||
|
if (satellitesGalileo > 0) parts.add("Galileo $satellitesGalileo")
|
||||||
|
if (satellitesBeidou > 0) parts.add("BeiDou $satellitesBeidou")
|
||||||
|
if (satellitesOther > 0) parts.add("прочие $satellitesOther")
|
||||||
|
tvSatellites.text = "исп. $satellitesUsed · ${parts.joinToString(" · ")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
|
when (event.sensor.type) {
|
||||||
|
Sensor.TYPE_ACCELEROMETER -> lowPass(event.values, lastGravity, 0.1f)
|
||||||
|
Sensor.TYPE_MAGNETIC_FIELD -> lowPass(event.values, lastGeo, 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val R = FloatArray(9)
|
||||||
|
val I = FloatArray(9)
|
||||||
|
if (SensorManager.getRotationMatrix(R, I, lastGravity, lastGeo)) {
|
||||||
|
val orientation = FloatArray(3)
|
||||||
|
SensorManager.getOrientation(R, orientation)
|
||||||
|
var deg = Math.toDegrees(orientation[0].toDouble()).toFloat()
|
||||||
|
heading = (deg + 360f) % 360f
|
||||||
|
compass.setData(heading, bearing, hasFix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||||
|
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
lastLocation = location
|
||||||
|
val target = Location("target").apply {
|
||||||
|
latitude = activeTarget.latitude
|
||||||
|
longitude = activeTarget.longitude
|
||||||
|
}
|
||||||
|
val dist = location.distanceTo(target)
|
||||||
|
bearing = (location.bearingTo(target) + 360f) % 360f
|
||||||
|
hasFix = true
|
||||||
|
|
||||||
|
compass.setData(heading, bearing, hasFix)
|
||||||
|
|
||||||
|
tvDistance.text = formatDistance(dist)
|
||||||
|
tvUnit.text = if (dist >= 1000f) "км до цели" else "метров до цели"
|
||||||
|
tvBearing.text = "${bearing.roundToInt()}° ${cardinal(bearing)}"
|
||||||
|
tvAltitude.text = "${location.altitude.roundToInt()} м"
|
||||||
|
tvAccuracy.text = if (location.accuracy > 0) "±${location.accuracy.roundToInt()} м" else "—"
|
||||||
|
tvStatus.text = "GPS: ${location.latitude.toString().take(8)}, ${location.longitude.toString().take(8)} · Цель: ${activeTarget.latitude.toString().take(8)}, ${activeTarget.longitude.toString().take(8)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProviderEnabled(provider: String) {}
|
||||||
|
override fun onProviderDisabled(provider: String) {}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (hasPermission()) {
|
||||||
|
try {
|
||||||
|
locationManager.requestLocationUpdates(
|
||||||
|
LocationManager.GPS_PROVIDER, 1000L, 1f, this
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= 24) {
|
||||||
|
locationManager.registerGnssStatusCallback(gnssCallback)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
locationManager.removeUpdates(this)
|
||||||
|
if (Build.VERSION.SDK_INT >= 24) {
|
||||||
|
locationManager.unregisterGnssStatusCallback(gnssCallback)
|
||||||
|
}
|
||||||
|
sensorManager.unregisterListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
locationManager.removeUpdates(this)
|
||||||
|
if (Build.VERSION.SDK_INT >= 24) {
|
||||||
|
locationManager.unregisterGnssStatusCallback(gnssCallback)
|
||||||
|
}
|
||||||
|
sensorManager.unregisterListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lowPass(input: FloatArray, output: FloatArray, alpha: Float) {
|
||||||
|
for (i in input.indices) {
|
||||||
|
if (output[i] == 0f) output[i] = input[i]
|
||||||
|
else output[i] = alpha * input[i] + (1f - alpha) * output[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDistance(meters: Float): String {
|
||||||
|
return if (meters >= 10000f) {
|
||||||
|
"%.1f".format(meters / 1000f)
|
||||||
|
} else if (meters >= 1000f) {
|
||||||
|
"%.2f".format(meters / 1000f)
|
||||||
|
} else {
|
||||||
|
meters.roundToInt().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cardinal(deg: Float): String {
|
||||||
|
val dirs = arrayOf("С", "СВ", "В", "ЮВ", "Ю", "ЮЗ", "З", "СЗ")
|
||||||
|
return dirs[((deg + 22.5f) / 45f).toInt() % 8]
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/src/main/java/com/ua1zbe/compass/TargetsStore.kt
Normal file
85
app/src/main/java/com/ua1zbe/compass/TargetsStore.kt
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package com.ua1zbe.compass
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class Target(val name: String, val latitude: Double, val longitude: Double)
|
||||||
|
|
||||||
|
object TargetsStore {
|
||||||
|
|
||||||
|
private const val PREFS = "targets"
|
||||||
|
private const val KEY = "list"
|
||||||
|
private const val KEY_ACTIVE = "active"
|
||||||
|
private const val KEY_VERSION = "version"
|
||||||
|
private const val VERSION = 2
|
||||||
|
|
||||||
|
val defaultTargets: List<Target> = listOf(
|
||||||
|
Target("П-Зори DMR", 67.371, 32.48967),
|
||||||
|
Target("R1ZBF-SVX", 67.3685, 32.49),
|
||||||
|
Target("Лысая Гора", 67.43083, 32.4535),
|
||||||
|
Target("Апатиты DMR", 67.55917, 33.40967),
|
||||||
|
Target("Кировск DMR", 67.602, 33.72767),
|
||||||
|
Target("Мончегорск DMR", 67.93883, 32.9375),
|
||||||
|
Target("R1ZCU-SVX", 69.00283, 33.102),
|
||||||
|
Target("R1ZAAG-HE", 69.0735, 33.43167),
|
||||||
|
Target("Цель 9", 67.371, 32.48967),
|
||||||
|
Target("Цель 10", 67.371, 32.48967),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun load(context: Context): MutableList<Target> {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
val version = prefs.getInt(KEY_VERSION, 0)
|
||||||
|
|
||||||
|
if (version != VERSION) {
|
||||||
|
prefs.edit()
|
||||||
|
.putInt(KEY_VERSION, VERSION)
|
||||||
|
.putInt(KEY_ACTIVE, 0)
|
||||||
|
.remove(KEY)
|
||||||
|
.apply()
|
||||||
|
return defaultTargets.toMutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val json = prefs.getString(KEY, null)
|
||||||
|
val list = defaultTargets.toMutableList()
|
||||||
|
if (!json.isNullOrEmpty()) {
|
||||||
|
try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
if (i < 10) {
|
||||||
|
list[i] = Target(
|
||||||
|
o.optString("name", list[i].name),
|
||||||
|
o.optDouble("lat", list[i].latitude),
|
||||||
|
o.optDouble("lon", list[i].longitude)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(context: Context, list: List<Target>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (i in list.indices) {
|
||||||
|
val o = JSONObject()
|
||||||
|
o.put("name", list[i].name)
|
||||||
|
o.put("lat", list[i].latitude)
|
||||||
|
o.put("lon", list[i].longitude)
|
||||||
|
arr.put(o)
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(KEY, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getActiveIndex(context: Context): Int {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getInt(KEY_ACTIVE, 0).coerceIn(0, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setActiveIndex(context: Context, index: Int) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putInt(KEY_ACTIVE, index.coerceIn(0, 9)).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/src/main/res/drawable/bg_gradient.xml
Normal file
8
app/src/main/res/drawable/bg_gradient.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:angle="270"
|
||||||
|
android:endColor="@color/bg_bottom"
|
||||||
|
android:startColor="@color/bg_top" />
|
||||||
|
</shape>
|
||||||
9
app/src/main/res/drawable/card_bg.xml
Normal file
9
app/src/main/res/drawable/card_bg.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/card_bg" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/card_stroke" />
|
||||||
|
</shape>
|
||||||
10
app/src/main/res/drawable/ic_launcher_bg.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_bg.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h108v108h-108z"
|
||||||
|
android:fillColor="#16233B" />
|
||||||
|
</vector>
|
||||||
19
app/src/main/res/drawable/ic_launcher_fg.xml
Normal file
19
app/src/main/res/drawable/ic_launcher_fg.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0h108v108h-108z"
|
||||||
|
android:fillColor="#16233B" />
|
||||||
|
<path
|
||||||
|
android:pathData="M54,24a30,30 0 1,0 0,60a30,30 0 1,0 0,-60z"
|
||||||
|
android:fillColor="#0B1020" />
|
||||||
|
<path
|
||||||
|
android:pathData="M54,34a20,20 0 1,0 0,40a20,20 0 1,0 0,-40z"
|
||||||
|
android:fillColor="#1B2A45" />
|
||||||
|
<path
|
||||||
|
android:pathData="M54,38 L61,54 L54,70 L47,54 Z"
|
||||||
|
android:fillColor="#29E0FF" />
|
||||||
|
</vector>
|
||||||
193
app/src/main/res/layout/activity_main.xml
Normal file
193
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/bg_gradient"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTitle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:letterSpacing="0.15"
|
||||||
|
android:text="ЦЕЛЬ: 67.370898, 32.489668"
|
||||||
|
android:textAllCaps="true"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:minHeight="300dp">
|
||||||
|
|
||||||
|
<com.ua1zbe.compass.CompassView
|
||||||
|
android:id="@+id/compass"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDistance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="— — —"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="34sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvUnit"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="км до цели"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/card_bg"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Азимут на цель"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBearing"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="@color/accent"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:background="@color/card_stroke" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Высота над уровнем моря"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAltitude"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="@color/accent_green"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:background="@color/card_stroke" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Спутники"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSatellites"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:background="@color/card_stroke" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Точность GPS"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAccuracy"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="—"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="Поиск спутников GPS…"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_bg" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_fg" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 642 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 457 B |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 865 B |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
12
app/src/main/res/values/colors.xml
Normal file
12
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="bg_top">#0B1020</color>
|
||||||
|
<color name="bg_bottom">#16233B</color>
|
||||||
|
<color name="card_bg">#1B2A45</color>
|
||||||
|
<color name="card_stroke">#2C3F63</color>
|
||||||
|
<color name="accent">#29E0FF</color>
|
||||||
|
<color name="accent_green">#3DDC97</color>
|
||||||
|
<color name="danger">#FF5A5F</color>
|
||||||
|
<color name="text_primary">#E8EEF7</color>
|
||||||
|
<color name="text_secondary">#8A97A8</color>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Компас Цель</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values/themes.xml
Normal file
9
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Compass" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/bg_top</item>
|
||||||
|
<item name="android:statusBarColor">@color/bg_top</item>
|
||||||
|
<item name="android:navigationBarColor">@color/bg_top</item>
|
||||||
|
<item name="android:colorAccent">@color/accent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||||
|
}
|
||||||
7
gradle.properties
Normal file
7
gradle.properties
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=384m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=false
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
org.gradle.caching=true
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
|
kotlin.daemon.jvmargs=-Xmx768m
|
||||||
|
android.useAndroidX=false
|
||||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "CompassApp"
|
||||||
|
include(":app")
|
||||||
101
ИНСТРУКЦИЯ.md
Normal file
101
ИНСТРУКЦИЯ.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Компас Цель — Android приложение (Kotlin)
|
||||||
|
|
||||||
|
Приложение показывает направление и расстояние от вашего текущего GPS-местоположения до выбранной цели (репитер, метка, объект). Работает в тёмной теме, с компасом и счётчиком спутников.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Дистанция до цели** в метрах/километрах — обновляется в реальном времени
|
||||||
|
- **Компас с фиксированным севером** — N всегда сверху:
|
||||||
|
- красная стрелка — направление на выбранную цель (абсолютный азимут)
|
||||||
|
- белый треугольник — куда направлен телефон
|
||||||
|
- стрелка скрыта, пока нет GPS-фикса
|
||||||
|
- **Азимут на цель** в градусах и словесно (С, СВ, В, ЮВ, Ю, ЮЗ, З, СЗ)
|
||||||
|
- **Высота** над уровнем моря
|
||||||
|
- **Спутники** — количество используемых и разбивка по созвездиям: GPS, ГЛОНАСС, Galileo, BeiDou
|
||||||
|
- **Точность GPS** (± метры)
|
||||||
|
- **10 целей** — выбор и редактирование (название, широта, долгота), сохранение между запусками
|
||||||
|
- **Вставка текущих координат** в поле цели одной кнопкой
|
||||||
|
- Экран не гаснет во время работы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. Установите APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
2. Запустите приложение
|
||||||
|
3. Разрешите доступ к геолокации (GPS) — без этого приложение не работает
|
||||||
|
4. Подождите фикса GPS (до 1–2 минут на улице)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Цели по умолчанию
|
||||||
|
|
||||||
|
| № | Название | Широта | Долгота |
|
||||||
|
|---|----------|--------|---------|
|
||||||
|
| 1 | П-Зори DMR | 67.371 | 32.48967 |
|
||||||
|
| 2 | R1ZBF-SVX | 67.3685 | 32.49 |
|
||||||
|
| 3 | Лысая Гора | 67.43083 | 32.4535 |
|
||||||
|
| 4 | Апатиты DMR | 67.55917 | 33.40967 |
|
||||||
|
| 5 | Кировск DMR | 67.602 | 33.72767 |
|
||||||
|
| 6 | Мончегорск DMR | 67.93883 | 32.9375 |
|
||||||
|
| 7 | R1ZCU-SVX | 69.00283 | 33.102 |
|
||||||
|
| 8 | R1ZAAG-HE | 69.0735 | 33.43167 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как пользоваться
|
||||||
|
|
||||||
|
### Выбор цели
|
||||||
|
- **Тапните по названию цели вверху экрана** (например «ЦЕЛЬ: П-Зори DMR ▾»)
|
||||||
|
- В открывшемся списке выберите нужную цель — дистанция и стрелка обновятся сразу
|
||||||
|
|
||||||
|
### Редактирование цели
|
||||||
|
1. Тапните по названию цели сверху → кнопка **«Редактировать…»**
|
||||||
|
2. Измените название, широту, долготу
|
||||||
|
3. **«Вставить текущие координаты»** — автоматически подставит ваше текущее местоположение
|
||||||
|
4. **«Сохранить»** — изменения сохраняются между запусками
|
||||||
|
|
||||||
|
### Чтение экрана
|
||||||
|
- **Центр круга** — дистанция до выбранной цели
|
||||||
|
- **Красная стрелка** — куда идти/смотреть на цель
|
||||||
|
- **Белый треугольник** — ориентация телефона
|
||||||
|
- **Карточка под компасом** — азимут, высота, спутники, точность GPS
|
||||||
|
- **Строка внизу** — ваши координаты и координаты цели
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сборка проекта
|
||||||
|
|
||||||
|
Требования: JDK 17+, Android SDK (platform 34), Gradle (используется 9.2.0).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# локальный SDK
|
||||||
|
echo "sdk.dir=/путь/к/Android/Sdk" > local.properties
|
||||||
|
|
||||||
|
# сборка debug APK (ограниченные ресурсы — под слабые ПК)
|
||||||
|
gradle :app:assembleDebug --no-daemon
|
||||||
|
|
||||||
|
# установка на телефон по USB (режим отладки включён)
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Ресурсы сборки ограничены в `gradle.properties` (heap 1 ГБ, 2 воркера, без параллелизма) — при мощном ПК можно убрать.
|
||||||
|
|
||||||
|
### Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/com/ua1zbe/compass/
|
||||||
|
├── MainActivity.kt — GPS, компас, сенсоры, меню и редактирование целей
|
||||||
|
├── CompassView.kt — отрисовка компаса (канвас)
|
||||||
|
└── TargetsStore.kt — хранение целей (SharedPreferences/JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Компас использует магнитометр и акселерометр — калибруйте телефон восьмёркой при неточных показаниях
|
||||||
|
- Высота берётся из GPS (приборная, над уровнем моря)
|
||||||
|
- ГЛОНАСС и другие созвездия видны автоматически через `GnssStatus` (Android 7+)
|
||||||
Reference in New Issue
Block a user