Initial commit: Компас Цель — Android приложение для навигации по GPS
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user