Initial commit: Конвертор координат GPS → APRS, Maidenhead

- Kotlin + Jetpack Compose (Material 3)
- Конвертация в APRS, Maidenhead, DMS форматы
- Полноэкранный режим
- Иконка приложения
- Min SDK: 21, Target SDK: 34

Версия: 1.0.0
Дата: 2026-03-02
Автор: UA1ZBE

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
UA1ZBE
2026-03-03 01:34:35 +03:00
parent c84b2d0feb
commit 17d5722560
32 changed files with 1326 additions and 2 deletions

46
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,46 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.aprs"
compileSdk = 34
defaultConfig {
applicationId = "com.example.aprs"
// Jetpack Compose requires API 21+
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.activity:activity-compose:1.8.0")
implementation("androidx.compose.ui:ui:1.5.0")
implementation("androidx.compose.ui:ui-graphics:1.5.0")
implementation("androidx.compose.material:material:1.5.0")
implementation("androidx.compose.material3:material3:1.1.2")
implementation("androidx.compose.material:material-icons-extended:1.5.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.aprs">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<application
android:allowBackup="true"
android:label="Конвертор координат"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.APRSApp">
<activity android:name="com.example.aprs.MainActivity"
android:exported="true">
<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,107 @@
package com.example.aprs
import kotlin.math.floor
import kotlin.math.abs
import kotlin.math.sin
import kotlin.math.cos
import kotlin.math.atan
import kotlin.math.sqrt
import kotlin.math.PI
import kotlin.math.ln
object LocationUtils {
// Convert decimal degrees to APRS position format: DDMM.MM N / DDDMM.MM E
fun toAprs(lat: Double, lon: Double): String {
val latHem = if (lat >= 0) "N" else "S"
val lonHem = if (lon >= 0) "E" else "W"
val latAbs = abs(lat)
val lonAbs = abs(lon)
val latDeg = floor(latAbs).toInt()
val latMin = (latAbs - latDeg) * 60.0
val lonDeg = floor(lonAbs).toInt()
val lonMin = (lonAbs - lonDeg) * 60.0
return String.format(
"%02d%05.2f%s/%03d%05.2f%s",
latDeg,
latMin,
latHem,
lonDeg,
lonMin,
lonHem
)
}
// Maidenhead locator (6 chars) from lat/lon
fun toMaidenhead(lat: Double, lon: Double): String {
var adjLon = lon + 180.0
var adjLat = lat + 90.0
val fieldLon = (adjLon / 20.0).toInt()
val fieldLat = (adjLat / 10.0).toInt()
val squareLon = ((adjLon % 20) / 2).toInt()
val squareLat = ((adjLat % 10) / 1).toInt()
val subsLon = (((adjLon - fieldLon * 20 - squareLon * 2) * 60) / 5).toInt()
val subsLat = (((adjLat - fieldLat * 10 - squareLat * 1) * 60) / 2.5).toInt()
val a = 'A'.code
val fieldChars = charArrayOf((a + fieldLon).toChar(), (a + fieldLat).toChar())
val squareChars = charArrayOf(('0'.code + squareLon).toChar(), ('0'.code + squareLat).toChar())
val subsChars = charArrayOf((a + subsLon).toChar(), (a + subsLat).toChar())
return String(charArrayOf(fieldChars[0], fieldChars[1], squareChars[0], squareChars[1], subsChars[0], subsChars[1]))
}
// Convert lat/lon to UTM coordinates
fun toUTM(lat: Double, lon: Double): String {
val k0 = 0.9996
val a = 6378137.0
val eSquared = 0.00669438
val e = sqrt(eSquared)
val ePrimeSquared = eSquared / (1 - eSquared)
val latRad = lat * PI / 180.0
val lonRad = lon * PI / 180.0
val zone = ((lon + 180) / 6).toInt() + 1
val lonOrigin = (zone - 1) * 6 - 180 + 3
val lonOriginRad = lonOrigin * PI / 180.0
val n = a / sqrt(1 - eSquared * sin(latRad) * sin(latRad))
val T = tan(latRad) * tan(latRad)
val C = ePrimeSquared * cos(latRad) * cos(latRad)
val A = cos(latRad) * (lonRad - lonOriginRad)
val M = a * ((1 - eSquared / 4 - 3 * eSquared * eSquared / 64 - 5 * eSquared * eSquared * eSquared / 256) * latRad
- (3 * eSquared / 8 + 3 * eSquared * eSquared / 32 + 45 * eSquared * eSquared * eSquared / 1024) * sin(2 * latRad)
+ (15 * eSquared * eSquared / 256 + 45 * eSquared * eSquared * eSquared / 1024) * sin(4 * latRad)
- (35 * eSquared * eSquared * eSquared / 3072) * sin(6 * latRad))
val UTMEasting = (k0 * n * (A + (1 - T + C) * A * A * A / 6
+ (5 - 18 * T + T * T + 72 * C - 58 * ePrimeSquared) * A * A * A * A * A / 120)
+ 500000.0).toLong()
val UTMNorthing = (k0 * (M + n * tan(latRad) * (A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24
+ (61 - 58 * T + T * T + 600 * C - 330 * ePrimeSquared) * A * A * A * A * A * A / 720))
+ (if (lat < 0) 10000000.0 else 0.0)).toLong()
val zoneLetter = getUtmZoneLetter(lat, lon)
return String.format("%d%s %06d %07d", zone, zoneLetter, UTMEasting, UTMNorthing)
}
private fun tan(rad: Double): Double = sin(rad) / cos(rad)
private fun getUtmZoneLetter(lat: Double, lon: Double): String {
val letters = "CDEFGHJKLMNPQRSTUVWXX"
val latIndex = ((lat + 80) / 8).toInt().coerceIn(0, letters.length - 1)
return letters[latIndex].toString()
}
}

View File

@@ -0,0 +1,340 @@
package com.example.aprs
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
var permissionGranted by mutableStateOf(false)
class MainActivity : ComponentActivity() {
internal val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
permissionGranted = granted
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Включаем полноэкранный режим
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.statusBars())
hide(WindowInsetsCompat.Type.navigationBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
// Делаем контент за статус-баром и навигацией
window.setFlags(
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
)
setContent {
MaterialTheme(
colorScheme = darkColorScheme()
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color(0xFF0D0D0D)
) {
CoordinateConverterScreen(this)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CoordinateConverterScreen(activity: MainActivity) {
var location by remember { mutableStateOf<Location?>(null) }
var isUpdating by remember { mutableStateOf(false) }
val context = activity.applicationContext
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
permissionGranted = granted
if (!granted) {
activity.permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
DisposableEffect(permissionGranted) {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val listener = object : LocationListener {
override fun onLocationChanged(loc: Location) {
location = loc
isUpdating = false
}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
}
if (permissionGranted) {
try {
isUpdating = true
val last = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER) ?: lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
if (last != null) {
location = last
isUpdating = false
}
lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 0f, listener)
} catch (e: SecurityException) {
isUpdating = false
}
}
onDispose {
lm.removeUpdates(listener)
}
}
val lat = location?.latitude ?: 0.0
val lon = location?.longitude ?: 0.0
val hasLocation = location != null
val aprs = if (hasLocation) LocationUtils.toAprs(lat, lon) else ""
val maiden = if (hasLocation) LocationUtils.toMaidenhead(lat, lon) else ""
val dmsLat = if (hasLocation) toDMS(lat, true) else ""
val dmsLon = if (hasLocation) toDMS(lon, false) else ""
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Конвертор координат",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = "GPS → APRS, Maidenhead",
fontSize = 14.sp,
color = Color.Gray,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// Status indicator
Row(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(
if (isUpdating) Brush.horizontalGradient(listOf(Color(0xFFFFA500), Color(0xFFFF6600)))
else if (hasLocation) Brush.horizontalGradient(listOf(Color(0xFF00C853), Color(0xFF69F0AE)))
else Brush.horizontalGradient(listOf(Color(0xFFB0BEC5), Color(0xFF78909C)))
)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isUpdating) Icons.Default.Refresh else if (hasLocation) Icons.Default.CheckCircle else Icons.Default.LocationOff,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isUpdating) "Получение GPS..." else if (hasLocation) "GPS активен" else "Нет GPS",
color = Color.White,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(24.dp))
// Decimal coordinates card
CoordinateCard(
title = "Десятичные координаты",
icon = Icons.Default.MyLocation,
gradient = listOf(Color(0xFF2196F3), Color(0xFF64B5F6))
) {
CoordinateRow(label = "Широта", value = if (hasLocation) String.format("%.6f°", lat) else "")
Spacer(modifier = Modifier.height(8.dp))
CoordinateRow(label = "Долгота", value = if (hasLocation) String.format("%.6f°", lon) else "")
}
Spacer(modifier = Modifier.height(12.dp))
// DMS coordinates card
CoordinateCard(
title = "Градусы, минуты, секунды",
icon = Icons.Default.Place,
gradient = listOf(Color(0xFF9C27B0), Color(0xFFBA68C8))
) {
CoordinateRow(label = "Широта", value = dmsLat)
Spacer(modifier = Modifier.height(8.dp))
CoordinateRow(label = "Долгота", value = dmsLon)
}
Spacer(modifier = Modifier.height(12.dp))
// APRS card
CoordinateCard(
title = "APRS формат",
icon = Icons.Default.Radio,
gradient = listOf(Color(0xFFFF5722), Color(0xFFFF8A65))
) {
Text(
text = aprs,
fontSize = 16.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = Color(0xFFFFCCBC),
modifier = Modifier.padding(vertical = 8.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
// Maidenhead card
CoordinateCard(
title = "Maidenhead (QTH локатор)",
icon = Icons.Default.GridOn,
gradient = listOf(Color(0xFF00BCD4), Color(0xFF4DD0E1))
) {
Text(
text = maiden,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = Color(0xFFB2EBF2),
modifier = Modifier.padding(vertical = 8.dp)
)
}
Spacer(modifier = Modifier.height(48.dp))
// Footer
Text(
text = "© 2026 UA1ZBE",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF9C27B0)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
fun CoordinateCard(
title: String,
icon: ImageVector,
gradient: List<Color>,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(Brush.horizontalGradient(gradient)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
Spacer(modifier = Modifier.height(16.dp))
content()
}
}
}
@Composable
fun CoordinateRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 14.sp,
color = Color.Gray
)
Text(
text = value,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
fun toDMS(coordinate: Double, isLatitude: Boolean): String {
val abs = kotlin.math.abs(coordinate)
val deg = abs.toInt()
val minFloat = (abs - deg) * 60
val min = minFloat.toInt()
val sec = (minFloat - min) * 60
val direction = when {
isLatitude -> if (coordinate >= 0) "N" else "S"
else -> if (coordinate >= 0) "E" else "W"
}
return String.format("%d° %d' %.2f\" %s", deg, min, sec, direction)
}

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="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1A1A1A</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,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.APRSApp" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:enforceNavigationBarContrast">false</item>
<item name="android:enforceStatusBarContrast">false</item>
</style>
</resources>