commit e92e7b06a2664a69159da0d79f258288eeefcf46 Author: ua1zbe Date: Tue Aug 11 14:11:07 2026 +0300 Initial commit: Компас Цель — Android приложение для навигации по GPS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4af4c21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.DS_Store +captures/ +.externalNativeBuild/ +.cxx/ +*.apk.idsig diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f87bed --- /dev/null +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..155eab5 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..777a511 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/ua1zbe/compass/CompassView.kt b/app/src/main/java/com/ua1zbe/compass/CompassView.kt new file mode 100644 index 0000000..bec954d --- /dev/null +++ b/app/src/main/java/com/ua1zbe/compass/CompassView.kt @@ -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() + } +} diff --git a/app/src/main/java/com/ua1zbe/compass/MainActivity.kt b/app/src/main/java/com/ua1zbe/compass/MainActivity.kt new file mode 100644 index 0000000..01104df --- /dev/null +++ b/app/src/main/java/com/ua1zbe/compass/MainActivity.kt @@ -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 = 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, 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] + } +} diff --git a/app/src/main/java/com/ua1zbe/compass/TargetsStore.kt b/app/src/main/java/com/ua1zbe/compass/TargetsStore.kt new file mode 100644 index 0000000..3ab332c --- /dev/null +++ b/app/src/main/java/com/ua1zbe/compass/TargetsStore.kt @@ -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 = 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 { + 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) { + 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() + } +} diff --git a/app/src/main/res/drawable/bg_gradient.xml b/app/src/main/res/drawable/bg_gradient.xml new file mode 100644 index 0000000..56a387c --- /dev/null +++ b/app/src/main/res/drawable/bg_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/card_bg.xml b/app/src/main/res/drawable/card_bg.xml new file mode 100644 index 0000000..7d5d10b --- /dev/null +++ b/app/src/main/res/drawable/card_bg.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_bg.xml b/app/src/main/res/drawable/ic_launcher_bg.xml new file mode 100644 index 0000000..571f605 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_bg.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_fg.xml b/app/src/main/res/drawable/ic_launcher_fg.xml new file mode 100644 index 0000000..3c868a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_fg.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..47f3bb4 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..4fefd60 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..1f07d4a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..b5e6830 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..79dc1a2 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6b9bc2f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..046ff80 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f826778 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,12 @@ + + + #0B1020 + #16233B + #1B2A45 + #2C3F63 + #29E0FF + #3DDC97 + #FF5A5F + #E8EEF7 + #8A97A8 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2322e2b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Компас Цель + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2789f12 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..34f518e --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..38f92e9 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e242b35 --- /dev/null +++ b/settings.gradle.kts @@ -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") diff --git a/ИНСТРУКЦИЯ.md b/ИНСТРУКЦИЯ.md new file mode 100644 index 0000000..b5081a5 --- /dev/null +++ b/ИНСТРУКЦИЯ.md @@ -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+)