Initial commit: Компас Цель — Android приложение для навигации по GPS

This commit is contained in:
ua1zbe
2026-08-11 14:11:07 +03:00
commit e92e7b06a2
25 changed files with 1192 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.gradle/
build/
local.properties
.idea/
*.iml
.DS_Store
captures/
.externalNativeBuild/
.cxx/
*.apk.idsig

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# Компас Цель (Compass Target)
Android-приложение на Kotlin для определения направления и расстояния до цели по GPS.
![Android](https://img.shields.io/badge/Android-7.0%2B-green) ![Kotlin](https://img.shields.io/badge/Kotlin-2.2.21-purple) ![License](https://img.shields.io/badge/License-GPLv3-blue)
## Описание
Приложение показывает на карте-компасе направление, расстояние и высоту до выбранной цели (репитер, метка, точка сбора). Интерфейс в тёмной теме, всё обновляется в реальном времени.
**Координаты цели (по умолчанию):** 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
View 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")
}

View 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>

View 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()
}
}

View 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]
}
}

View 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()
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Компас Цель</string>
</resources>

View 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
View 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
View 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
View 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
View 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 (до 12 минут на улице)
---
## Цели по умолчанию
| № | Название | Широта | Долгота |
|---|----------|--------|---------|
| 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+)