Compare commits

...

5 Commits

Author SHA1 Message Date
Thorsten 5019c83b80 Wegpunkt-Pfeile mit Richtung und Info-Fenster; GPX-Schreib-Performance fix
- TrackDetailActivity: Zeigt zoom-adaptive Richtungspfeile (Bearing) als Marker auf der Karte; Klick öffnet Info-Fenster mit Geschwindigkeit und Kurs
- GPXManager: appendLocation schreibt nur noch per appendText statt Datei komplett neu zu schreiben (O(1) statt O(n))
- TrackingService: finalizeSession() beim Stoppen aufrufen, um GPX-Footer korrekt abzuschließen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:52:27 +02:00
Thorsten 8de7b4d886 jetzt mit Google Drive ZIP Backup 2026-03-06 11:27:44 +01:00
Thorsten a455be8db7 Geschwindigkeit und Anzeigen korrigiert 2026-03-06 10:05:16 +01:00
Thorsten 3d2d68d145 wear und applaufen, allerdings wear ohne kmh 2026-03-06 08:20:54 +01:00
Thorsten c2cfcda942 wear und applaufen, allerdings wear ohne kmh 2026-03-06 08:20:48 +01:00
42 changed files with 3064 additions and 188 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Read(//c/Users/thors/AppData/Local/Android/**)"
]
}
}
+12 -4
View File
@@ -4,7 +4,18 @@
<selectionStates>
<SelectionState runConfigName="mobile">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-03T11:02:54.897757100Z">
<DropdownSelection timestamp="2026-03-03T15:09:55.401831700Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\thors\.android\avd\Wear_OS_Large_Round.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="gpstracker">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-06T08:51:20.686825100Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=28161JEEJR069K" />
@@ -13,9 +24,6 @@
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="wear">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-1
View File
@@ -1,6 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+8
View File
@@ -7,6 +7,7 @@ espressoCore = "3.7.0"
appcompat = "1.7.1"
playServicesWearable = "19.0.0"
playServicesLocation = "21.3.0"
playServicesMaps = "19.1.0"
material = "1.13.0"
activity = "1.12.4"
constraintlayout = "2.2.1"
@@ -18,6 +19,8 @@ wearToolingPreview = "1.0.0"
activityCompose = "1.12.4"
coreSplashscreen = "1.2.0"
kotlinxCoroutines = "1.9.0"
gson = "2.10.1"
documentfile = "1.0.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -27,6 +30,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
@@ -46,7 +50,11 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+27 -9
View File
@@ -1,23 +1,29 @@
import java.util.Properties
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.reader().use { localProperties.load(it) }
}
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "de.tstratmann.gpstracker_wear"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
namespace = "de.tstratmann.gpstracker"
compileSdk = 36
defaultConfig {
applicationId = "de.tstratmann.gpstracker_wear"
applicationId = "de.tstratmann.gpstracker"
minSdk = 30
targetSdk = 36
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders["MAPS_API_KEY"] = localProperties.getProperty("MAPS_API_KEY", "")
}
buildTypes {
@@ -29,21 +35,33 @@ android {
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.play.services.wearable)
implementation(libs.play.services.location)
implementation(libs.play.services.maps)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.gson)
implementation(libs.androidx.documentfile)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
}
+55 -10
View File
@@ -2,6 +2,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,7 +22,39 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GPSTracker_Wear">
android:theme="@style/Theme.GPSTracker">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ActivitiesActivity"
android:exported="false"
android:screenOrientation="portrait"
android:parentActivityName=".MainActivity" />
<activity
android:name=".TrackDetailActivity"
android:exported="false"
android:screenOrientation="portrait"
android:parentActivityName=".ActivitiesActivity" />
<service
android:name=".TrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location" />
<service
android:name=".MobileSyncService"
@@ -22,14 +66,15 @@
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
</manifest>
@@ -0,0 +1,385 @@
package de.tstratmann.gpstracker
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import de.tstratmann.gpstracker.databinding.ActivityActivitiesBinding
import androidx.documentfile.provider.DocumentFile
import com.google.android.material.card.MaterialCardView
import android.widget.TextView
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class ActivitiesActivity : AppCompatActivity() {
private lateinit var binding: ActivityActivitiesBinding
private lateinit var trackStorage: TrackStorage
private lateinit var gpxExporter: GpxExporter
private lateinit var gpxImporter: GpxImporter
private val gson = Gson()
private val importGpxLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
uri?.let { importGpxFile(it) }
}
// Backup: Wir erstellen eine ZIP-Datei
private val createBackupLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
uri?.let { performDriveBackup(it) }
}
// Restore: Wir wählen eine ZIP-Datei aus
private val restoreBackupLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { performDriveRestore(it) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityActivitiesBinding.inflate(layoutInflater)
setContentView(binding.root)
trackStorage = TrackStorage(this)
gpxExporter = GpxExporter(this)
gpxImporter = GpxImporter(this)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Aktivitäten"
updateStorageInfo()
loadAndDisplayTracks()
}
override fun onResume() {
super.onResume()
loadAndDisplayTracks()
updateStorageInfo()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_activities, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_drive_backup -> {
val date = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(java.util.Date())
createBackupLauncher.launch("gpstracker_backup_$date.zip")
true
}
R.id.action_drive_restore -> {
restoreBackupLauncher.launch(arrayOf("application/zip", "application/x-zip-compressed"))
true
}
R.id.action_import_gpx -> {
importGpxLauncher.launch("*/*")
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun performDriveBackup(fileUri: Uri) {
val tracks = trackStorage.getAllTracks()
if (tracks.isEmpty()) {
Toast.makeText(this, "Keine Tracks zum Sichern", Toast.LENGTH_SHORT).show()
return
}
lifecycleScope.launch(Dispatchers.IO) {
try {
contentResolver.openOutputStream(fileUri)?.use { os ->
ZipOutputStream(BufferedOutputStream(os)).use { zos ->
tracks.forEach { track ->
val safeName = trackStorage.sanitizeFileName(track.name)
// JSON Datei in ZIP
val jsonEntry = ZipEntry("$safeName.json")
zos.putNextEntry(jsonEntry)
zos.write(gson.toJson(track).toByteArray(Charsets.UTF_8))
zos.closeEntry()
// GPX Datei in ZIP
val gpxEntry = ZipEntry("$safeName.gpx")
zos.putNextEntry(gpxEntry)
zos.write(gpxExporter.generateGpxContent(track).toByteArray(Charsets.UTF_8))
zos.closeEntry()
}
}
}
withContext(Dispatchers.Main) {
Toast.makeText(this@ActivitiesActivity, "${tracks.size} Tracks in Backup gesichert", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(this@ActivitiesActivity, "Backup fehlgeschlagen: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
private fun performDriveRestore(fileUri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
try {
var restored = 0
var skipped = 0
contentResolver.openInputStream(fileUri)?.use { inputStream ->
ZipInputStream(inputStream).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory && entry.name.endsWith(".json")) {
val content = zis.readBytes().toString(Charsets.UTF_8)
val track = runCatching { gson.fromJson(content, Track::class.java) }.getOrNull()
if (track != null) {
if (trackStorage.getTrack(track.name) == null) {
trackStorage.saveTrack(track)
restored++
} else {
skipped++
}
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
}
withContext(Dispatchers.Main) {
val msg = buildString {
append("$restored Track(s) wiederhergestellt")
if (skipped > 0) append(", $skipped bereits vorhanden")
}
Toast.makeText(this@ActivitiesActivity, msg, Toast.LENGTH_LONG).show()
loadAndDisplayTracks()
updateStorageInfo()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(this@ActivitiesActivity, "Restore fehlgeschlagen: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
private fun updateStorageInfo() {
val trackCount = trackStorage.getTrackCount()
binding.storageInfoText.text = "$trackCount Track(s) gespeichert\n${trackStorage.getStoragePath()}"
}
private fun loadAndDisplayTracks() {
val tracks = trackStorage.getAllTracks()
binding.tracksContainer.removeAllViews()
if (tracks.isEmpty()) {
val emptyText = TextView(this).apply {
text = "Noch keine Tracks aufgezeichnet"
textSize = 16f
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
setPadding(32, 32, 32, 32)
}
binding.tracksContainer.addView(emptyText)
} else {
tracks.forEach { track ->
val trackCard = createTrackCard(track)
binding.tracksContainer.addView(trackCard)
}
}
}
private fun createTrackCard(track: Track): MaterialCardView {
val card = MaterialCardView(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, 0, 12)
}
radius = 8f
cardElevation = 3f
setCardBackgroundColor(ContextCompat.getColor(context, android.R.color.white))
}
val cardContent = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(16, 16, 16, 16)
}
val nameText = TextView(this).apply {
text = track.name.replace("_", " ")
textSize = 15f
setTextColor(ContextCompat.getColor(context, android.R.color.black))
setTypeface(null, android.graphics.Typeface.BOLD)
setPadding(0, 0, 0, 10)
}
cardContent.addView(nameText)
val statsGrid = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
weightSum = 2f
}
val leftColumn = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
leftColumn.addView(createStatRow("Dauer", track.getFormattedDuration()))
leftColumn.addView(createStatRow("Distanz", track.getFormattedDistance()))
val rightColumn = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
rightColumn.addView(createStatRow("Ø Geschw.", track.getFormattedAverageSpeed()))
rightColumn.addView(createStatRow("Max. Geschw.", track.getFormattedMaxSpeed()))
statsGrid.addView(leftColumn)
statsGrid.addView(rightColumn)
cardContent.addView(statsGrid)
val buttonsRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 10, 0, 0)
}
}
val buttonHeightPx = (36 * resources.displayMetrics.density).toInt()
val viewButton = com.google.android.material.button.MaterialButton(
this, null, com.google.android.material.R.attr.materialButtonOutlinedStyle
).apply {
text = "Anzeigen"
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, buttonHeightPx, 1f).apply { setMargins(0, 0, 4, 0) }
minimumHeight = 0; minHeight = 0
setPadding(8, 4, 8, 4)
setOnClickListener { openTrackDetail(track) }
}
val exportButton = com.google.android.material.button.MaterialButton(
this, null, com.google.android.material.R.attr.materialButtonOutlinedStyle
).apply {
text = "GPX"
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, buttonHeightPx, 1f).apply { setMargins(4, 0, 4, 0) }
minimumHeight = 0; minHeight = 0
setPadding(8, 4, 8, 4)
setOnClickListener { exportTrack(track) }
}
val deleteButton = com.google.android.material.button.MaterialButton(
this, null, com.google.android.material.R.attr.materialButtonOutlinedStyle
).apply {
text = "Löschen"
textSize = 11f
setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark))
layoutParams = LinearLayout.LayoutParams(0, buttonHeightPx, 1f).apply { setMargins(4, 0, 0, 0) }
minimumHeight = 0; minHeight = 0
setPadding(8, 4, 8, 4)
setOnClickListener { deleteTrack(track) }
}
buttonsRow.addView(viewButton)
buttonsRow.addView(exportButton)
buttonsRow.addView(deleteButton)
cardContent.addView(buttonsRow)
card.addView(cardContent)
return card
}
private fun createStatRow(label: String, value: String): LinearLayout {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 0, 0, 8)
addView(TextView(context).apply {
text = label
textSize = 10f
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
})
addView(TextView(context).apply {
text = value
textSize = 14f
setTextColor(ContextCompat.getColor(context, android.R.color.black))
setTypeface(null, android.graphics.Typeface.BOLD)
})
}
}
private fun openTrackDetail(track: Track) {
val intent = Intent(this, TrackDetailActivity::class.java).apply {
putExtra(TrackDetailActivity.EXTRA_TRACK_NAME, track.name)
}
startActivity(intent)
}
private fun exportTrack(track: Track) {
gpxExporter.exportAndShare(track)
}
private fun importGpxFile(uri: Uri) {
try {
val track = gpxImporter.importFromUri(uri)
if (track != null) {
if (trackStorage.getTrack(track.name) != null) {
Toast.makeText(this, "Track '${track.name}' bereits vorhanden", Toast.LENGTH_LONG).show()
} else {
trackStorage.saveTrack(track)
Toast.makeText(this, "Track importiert: ${track.name}", Toast.LENGTH_LONG).show()
loadAndDisplayTracks()
updateStorageInfo()
}
} else {
Toast.makeText(this, "Fehler beim Importieren der GPX-Datei", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
Toast.makeText(this, "Import fehlgeschlagen: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun deleteTrack(track: Track) {
AlertDialog.Builder(this)
.setTitle("Track löschen?")
.setMessage("Möchtest du \"${track.name}\" wirklich löschen?")
.setPositiveButton("Löschen") { _, _ ->
trackStorage.deleteTrack(track.name)
Toast.makeText(this, "Track gelöscht", Toast.LENGTH_SHORT).show()
updateStorageInfo()
loadAndDisplayTracks()
}
.setNegativeButton("Abbrechen", null)
.show()
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
}
@@ -0,0 +1,19 @@
package de.tstratmann.gpstracker
enum class ActivityType(
val label: String,
val updateIntervalMs: Long,
val minDistanceM: Float,
val useKnots: Boolean
) {
WING_FOILING("WingFoilen", 1000L, 1f, true),
SUP("SUP", 1000L, 1f, true),
CYCLING("Radfahren", 1000L, 3f, false),
RUNNING("Laufen", 1000L, 3f, false),
HIKING("Wandern", 1000L, 3f, false),
OTHER("Sonstiges", 1000L, 1f, false);
fun formatSpeed(metersPerSecond: Float): String {
return "${(metersPerSecond * 3.6).toInt()} km/h"
}
}
@@ -0,0 +1,131 @@
package de.tstratmann.gpstracker
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.*
class GpxExporter(private val context: Context) {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
fun exportAndShare(track: Track) {
val gpxContent = generateGpxContent(track)
val file = saveGpxFile(track.name, gpxContent)
shareGpxFile(file)
}
fun generateGpxContent(track: Track): String {
val builder = StringBuilder()
builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
builder.append("<gpx version=\"1.1\" creator=\"GPS Tracker App\" ")
builder.append("xmlns=\"http://www.topografix.com/GPX/1/1\" ")
builder.append("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" ")
builder.append("xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 ")
builder.append("http://www.topografix.com/GPX/1/1/gpx.xsd\">\n")
builder.append(" <metadata>\n")
builder.append(" <name>${escapeXml(track.name)}</name>\n")
builder.append(" <time>${dateFormat.format(Date(track.createdAt))}</time>\n")
builder.append(" </metadata>\n")
builder.append(" <trk>\n")
builder.append(" <name>${escapeXml(track.name)}</name>\n")
builder.append(" <trkseg>\n")
for (point in track.points) {
builder.append(" <trkpt lat=\"${point.latitude}\" lon=\"${point.longitude}\">\n")
builder.append(" <ele>${point.altitude}</ele>\n")
builder.append(" <time>${dateFormat.format(Date(point.timestamp))}</time>\n")
if (point.speed > 0 || point.bearing > 0) {
builder.append(" <extensions>\n")
if (point.speed > 0) builder.append(" <speed>${point.speed}</speed>\n")
if (point.bearing > 0) builder.append(" <course>${point.bearing}</course>\n")
builder.append(" </extensions>\n")
}
builder.append(" </trkpt>\n")
}
builder.append(" </trkseg>\n")
builder.append(" </trk>\n")
builder.append("</gpx>")
return builder.toString()
}
private fun saveGpxFile(trackName: String, content: String): File {
val fileName = sanitizeFileName(trackName) + ".gpx"
val file = File(context.cacheDir, fileName)
FileWriter(file).use { writer ->
writer.write(content)
}
return file
}
fun saveGpxToPermanentStorage(track: Track): File? {
return try {
val baseDir = context.getExternalFilesDir(null)
?: context.filesDir
val exportDir = File(baseDir, "exports")
if (!exportDir.exists()) {
exportDir.mkdirs()
}
val fileName = sanitizeFileName(track.name) + ".gpx"
val file = File(exportDir, fileName)
val gpxContent = generateGpxContent(track)
FileWriter(file).use { writer ->
writer.write(gpxContent)
}
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9.-_]"), "_")
}
private fun shareGpxFile(file: File) {
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "application/gpx+xml"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(shareIntent, "GPX Track teilen")
)
}
private fun escapeXml(text: String): String {
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
}
}
@@ -0,0 +1,155 @@
package de.tstratmann.gpstracker
import android.content.Context
import android.net.Uri
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.*
class GpxImporter(private val context: Context) {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
fun importFromUri(uri: Uri): Track? {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
parseGpxFile(inputStream)
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun importFromString(content: String): Track? {
return try {
parseGpxFile(content.byteInputStream(Charsets.UTF_8))
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun parseGpxFile(inputStream: InputStream): Track? {
val points = mutableListOf<TrackPoint>()
var trackName = "Imported Track ${SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN).format(Date())}"
var totalDistance = 0.0
try {
val factory = XmlPullParserFactory.newInstance()
val parser = factory.newPullParser()
parser.setInput(inputStream, "UTF-8")
var eventType = parser.eventType
var currentLat: Double? = null
var currentLon: Double? = null
var currentEle: Double = 0.0
var currentTime: Long = 0L
var currentSpeed: Float = 0f
var currentBearing: Float = 0f
var inTrkpt = false
var inMetadata = false
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
when (parser.name) {
"metadata" -> inMetadata = true
"name" -> {
if (inMetadata && parser.next() == XmlPullParser.TEXT) {
trackName = parser.text
}
}
"trkpt" -> {
inTrkpt = true
currentLat = parser.getAttributeValue(null, "lat")?.toDoubleOrNull()
currentLon = parser.getAttributeValue(null, "lon")?.toDoubleOrNull()
}
"ele" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentEle = parser.text?.toDoubleOrNull() ?: 0.0
}
}
"time" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentTime = try {
dateFormat.parse(parser.text)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
}
"speed" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentSpeed = parser.text?.toFloatOrNull() ?: 0f
}
}
"course" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentBearing = parser.text?.toFloatOrNull() ?: 0f
}
}
}
}
XmlPullParser.END_TAG -> {
when (parser.name) {
"metadata" -> inMetadata = false
"trkpt" -> {
if (currentLat != null && currentLon != null) {
points.add(
TrackPoint(
latitude = currentLat,
longitude = currentLon,
altitude = currentEle,
speed = currentSpeed,
timestamp = currentTime,
accuracy = 0f,
bearing = currentBearing
)
)
}
inTrkpt = false
currentLat = null
currentLon = null
currentEle = 0.0
currentSpeed = 0f
currentBearing = 0f
currentTime = 0L
}
}
}
}
eventType = parser.next()
}
if (points.size > 1) {
for (i in 1 until points.size) {
totalDistance += LocationHelper.calculateDistance(
points[i - 1].latitude,
points[i - 1].longitude,
points[i].latitude,
points[i].longitude
)
}
}
return if (points.isNotEmpty()) {
Track(
name = trackName,
points = points,
totalDistance = totalDistance,
createdAt = points.firstOrNull()?.timestamp ?: System.currentTimeMillis()
)
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}
@@ -0,0 +1,51 @@
package de.tstratmann.gpstracker
import kotlin.math.*
object LocationHelper {
fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val earthRadius = 6371.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadius * c
}
fun calculateTotalDistance(points: List<TrackPoint>): Double {
if (points.size < 2) return 0.0
var totalDistance = 0.0
for (i in 1 until points.size) {
val previous = points[i - 1]
val current = points[i]
totalDistance += calculateDistance(
previous.latitude,
previous.longitude,
current.latitude,
current.longitude
)
}
return totalDistance
}
fun metersPerSecondToKmh(mps: Float): Int {
return (mps * 3.6).roundToInt()
}
fun metersPerSecondToKnots(mps: Float): Int {
return (mps * 1.94384).roundToInt()
}
fun formatCoordinates(latitude: Double, longitude: Double): String {
return "%.6f, %.6f".format(latitude, longitude)
}
}
@@ -0,0 +1,513 @@
package de.tstratmann.gpstracker
import android.Manifest
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.Color
import android.location.Location
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import de.tstratmann.gpstracker.databinding.ActivityMainBinding
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.*
import java.text.SimpleDateFormat
import java.util.*
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var binding: ActivityMainBinding
private lateinit var googleMap: GoogleMap
private lateinit var trackStorage: TrackStorage
private lateinit var fusedLocationClient: FusedLocationProviderClient
private var trackingService: TrackingService? = null
private var serviceBound = false
private var isTracking = false
private var selectedActivityType: ActivityType = ActivityType.WING_FOILING
private var currentMarker: Marker? = null
private var trackPolyline: Polyline? = null
private val trackLatLngs = mutableListOf<LatLng>()
private val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN)
private var trackingStartTime: Long = 0
private val handler = Handler(Looper.getMainLooper())
private val durationUpdateRunnable = object : Runnable {
override fun run() {
if (isTracking) {
updateDuration()
handler.postDelayed(this, 1000)
}
}
}
private val windDataClient = WindDataClient()
private var lastWindUpdateTime: Long = 0
private val WIND_UPDATE_INTERVAL = 60000L
private var currentWindData: WindData? = null
private var currentWindLatitude: Double = 0.0
private var currentWindLongitude: Double = 0.0
// Receiver for newly synced wear tracks
private val syncReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
showToast("Neuer Wear-Track synchronisiert")
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
if (allGranted) {
enableMyLocation()
moveToCurrentLocation()
} else {
showToast("GPS-Berechtigung erforderlich!")
}
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as TrackingService.TrackingBinder
trackingService = binder.getService()
serviceBound = true
trackingService?.onLocationUpdate = { trackPoint, totalDistance ->
runOnUiThread {
updateMapWithLocation(trackPoint)
updateStats(trackPoint, totalDistance)
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceBound = false
trackingService = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
trackStorage = TrackStorage(this)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
val mapFragment = supportFragmentManager
.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
setupButtons()
bindTrackingService()
checkPermissions()
}
override fun onResume() {
super.onResume()
val filter = IntentFilter(MobileSyncService.ACTION_TRACK_SYNCED)
ContextCompat.registerReceiver(this, syncReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onPause() {
super.onPause()
unregisterReceiver(syncReceiver)
}
override fun onMapReady(map: GoogleMap) {
googleMap = map
googleMap.apply {
mapType = GoogleMap.MAP_TYPE_NORMAL
uiSettings.isZoomControlsEnabled = true
uiSettings.isCompassEnabled = true
uiSettings.isMyLocationButtonEnabled = true
}
googleMap.setOnCameraMoveListener {
updateWindArrowRotation()
}
if (hasLocationPermission()) {
enableMyLocation()
moveToCurrentLocation()
}
trackPolyline = googleMap.addPolyline(
PolylineOptions()
.color(Color.RED)
.width(8f)
.geodesic(true)
)
}
private fun moveToCurrentLocation() {
if (!hasLocationPermission()) return
try {
fusedLocationClient.lastLocation.addOnSuccessListener { location: Location? ->
location?.let {
val currentLatLng = LatLng(it.latitude, it.longitude)
googleMap.animateCamera(
CameraUpdateFactory.newLatLngZoom(currentLatLng, 17f)
)
fetchWindData(it.latitude, it.longitude)
}
}
} catch (e: SecurityException) {
e.printStackTrace()
}
}
private fun setupButtons() {
binding.startStopButton.setOnClickListener {
toggleTracking()
}
binding.activitiesButton.setOnClickListener {
openActivitiesScreen()
}
binding.mapTypeButton.setOnClickListener {
toggleMapType()
}
binding.windWebButton.setOnClickListener {
openWindWebsite()
}
}
private fun toggleMapType() {
googleMap.mapType = when (googleMap.mapType) {
GoogleMap.MAP_TYPE_NORMAL -> {
showToast("Satellit")
GoogleMap.MAP_TYPE_SATELLITE
}
GoogleMap.MAP_TYPE_SATELLITE -> {
showToast("Hybrid")
GoogleMap.MAP_TYPE_HYBRID
}
else -> {
showToast("Karte")
GoogleMap.MAP_TYPE_NORMAL
}
}
}
private fun toggleTracking() {
if (!hasLocationPermission()) {
checkPermissions()
return
}
if (isTracking) {
stopTracking()
} else {
showActivitySelectionDialog()
}
}
private fun showActivitySelectionDialog() {
val activities = ActivityType.entries.toTypedArray()
val labels = activities.map { it.label }.toTypedArray()
var selectedIndex = activities.indexOf(selectedActivityType)
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("Aktivität wählen")
.setSingleChoiceItems(labels, selectedIndex) { _, which ->
selectedIndex = which
}
.setPositiveButton("Start") { _, _ ->
selectedActivityType = activities[selectedIndex]
startTracking()
}
.setNegativeButton("Abbrechen", null)
.show()
}
private fun startTracking() {
trackingService?.clearTrack()
trackLatLngs.clear()
trackPolyline?.points = trackLatLngs
trackingStartTime = System.currentTimeMillis()
binding.durationText.text = "00:00:00"
binding.distanceText.text = "0.00 km"
binding.speedText.text = "0 km/h"
binding.headingText.text = "0° Grad"
trackingService?.startTracking(selectedActivityType)
isTracking = true
handler.post(durationUpdateRunnable)
binding.startStopButton.apply {
text = "Stop"
setBackgroundColor(ContextCompat.getColor(this@MainActivity, android.R.color.holo_red_dark))
}
binding.statusText.text = selectedActivityType.label
binding.statusCard.setCardBackgroundColor(Color.RED)
binding.activitiesButton.visibility = android.view.View.GONE
showToast("GPS Tracking gestartet")
}
private fun stopTracking() {
trackingService?.stopTracking()
isTracking = false
handler.removeCallbacks(durationUpdateRunnable)
saveCurrentTrack()
binding.startStopButton.apply {
text = "Start"
setBackgroundColor(ContextCompat.getColor(this@MainActivity, android.R.color.holo_green_dark))
}
binding.statusText.text = "Gestoppt"
binding.statusCard.setCardBackgroundColor(Color.WHITE)
binding.activitiesButton.visibility = android.view.View.VISIBLE
showToast("GPS Tracking gestoppt")
}
private fun updateDuration() {
val durationMillis = System.currentTimeMillis() - trackingStartTime
val seconds = (durationMillis / 1000) % 60
val minutes = (durationMillis / (1000 * 60)) % 60
val hours = (durationMillis / (1000 * 60 * 60))
binding.durationText.text = String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
private fun updateMapWithLocation(trackPoint: TrackPoint) {
val latLng = LatLng(trackPoint.latitude, trackPoint.longitude)
if (currentMarker == null) {
currentMarker = googleMap.addMarker(
MarkerOptions()
.position(latLng)
.title("Aktuelle Position")
.icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE))
)
} else {
currentMarker?.position = latLng
}
trackLatLngs.add(latLng)
trackPolyline?.points = trackLatLngs
googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, 17f))
updateWindDataIfNeeded(trackPoint.latitude, trackPoint.longitude)
}
private fun updateStats(trackPoint: TrackPoint, totalDistance: Double) {
binding.distanceText.text = "%.2f km".format(totalDistance)
binding.speedText.text = selectedActivityType.formatSpeed(trackPoint.speed)
val bearing = getBearingInDegrees(trackPoint)
binding.headingText.text = "${bearing.toInt()}° Grad"
}
private fun getBearingInDegrees(trackPoint: TrackPoint): Float {
val points = trackingService?.getTrackPoints() ?: emptyList()
if (points.size < 2) return 0f
val previousPoint = points[points.size - 2]
return calculateBearing(
previousPoint.latitude,
previousPoint.longitude,
trackPoint.latitude,
trackPoint.longitude
)
}
private fun calculateBearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
val lat1Rad = Math.toRadians(lat1)
val lat2Rad = Math.toRadians(lat2)
val lonDiff = Math.toRadians(lon2 - lon1)
val y = Math.sin(lonDiff) * Math.cos(lat2Rad)
val x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(lonDiff)
var bearing = Math.toDegrees(Math.atan2(y, x))
bearing = (bearing + 360) % 360
return bearing.toFloat()
}
private fun saveCurrentTrack() {
val points = trackingService?.getTrackPoints() ?: emptyList()
if (points.isEmpty()) return
val activity = trackingService?.getActivityType() ?: selectedActivityType
val trackName = "${activity.label} ${dateFormat.format(Date())}"
val totalDistance = trackingService?.getTotalDistance() ?: 0.0
val track = Track(
name = trackName,
activityType = activity,
points = points,
totalDistance = totalDistance
)
trackStorage.saveTrack(track)
showToast("Track gespeichert: $trackName")
}
private fun openActivitiesScreen() {
val intent = Intent(this, ActivitiesActivity::class.java)
startActivity(intent)
}
private fun openWindWebsite() {
if (currentWindData?.isAvailable == true) {
val url = "https://open-meteo.com/en/docs#latitude=$currentWindLatitude&longitude=$currentWindLongitude&current=wind_speed_10m,wind_direction_10m"
val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))
startActivity(intent)
} else {
showToast("Keine Wind-Daten verfügbar")
}
}
private fun bindTrackingService() {
val intent = Intent(this, TrackingService::class.java)
startService(intent)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun enableMyLocation() {
if (hasLocationPermission()) {
try {
googleMap.isMyLocationEnabled = true
} catch (e: SecurityException) {
e.printStackTrace()
}
}
}
private fun hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
private fun checkPermissions() {
val permissions = mutableListOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
permissionLauncher.launch(permissions.toTypedArray())
}
private fun updateWindDataIfNeeded(latitude: Double, longitude: Double) {
val currentTime = System.currentTimeMillis()
if (currentTime - lastWindUpdateTime >= WIND_UPDATE_INTERVAL) {
lastWindUpdateTime = currentTime
fetchWindData(latitude, longitude)
}
}
private fun fetchWindData(latitude: Double, longitude: Double) {
currentWindLatitude = latitude
currentWindLongitude = longitude
lifecycleScope.launch {
try {
val windData = windDataClient.fetchWindData(latitude, longitude)
withContext(Dispatchers.Main) {
if (windData != null) {
currentWindData = windData
updateWindDisplay(windData)
} else {
currentWindData = WindData.unavailable()
updateWindDisplay(currentWindData!!)
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
currentWindData = WindData.unavailable()
updateWindDisplay(currentWindData!!)
}
}
}
}
private fun updateWindDisplay(windData: WindData) {
binding.windSpeedText.text = windData.getFormattedSpeed()
binding.windDirectionText.text = windData.getFormattedDirection()
if (windData.isAvailable) {
binding.windLocationText.text = "Lokation: %.4f, %.4f".format(currentWindLatitude, currentWindLongitude)
} else {
binding.windLocationText.text = "Lokation: --"
}
if (windData.isAvailable) {
binding.windArrow.visibility = android.view.View.VISIBLE
updateWindArrowRotation()
} else {
binding.windArrow.visibility = android.view.View.GONE
}
}
private fun updateWindArrowRotation() {
val windData = currentWindData
if (windData?.isAvailable == true) {
val mapBearing = googleMap.cameraPosition.bearing
val adjustedRotation = windData.direction - mapBearing
binding.windArrow.rotation = adjustedRotation
}
}
private fun showToast(message: String) {
val toast = Toast.makeText(this, message, Toast.LENGTH_SHORT)
toast.setGravity(Gravity.CENTER, 0, 0)
toast.show()
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacks(durationUpdateRunnable)
if (serviceBound) {
unbindService(serviceConnection)
serviceBound = false
}
}
}
@@ -0,0 +1,74 @@
package de.tstratmann.gpstracker
import android.content.Intent
import android.util.Log
import com.google.android.gms.wearable.*
import kotlinx.coroutines.*
import kotlinx.coroutines.tasks.await
class MobileSyncService : WearableListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
companion object {
const val ACTION_TRACK_SYNCED = "de.tstratmann.gpstracker.TRACK_SYNCED"
private const val TAG = "MobileSyncService"
}
override fun onDataChanged(dataEvents: DataEventBuffer) {
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
val path = event.dataItem.uri.path
if (path?.startsWith("/gpx_export/") == true) {
val dataMapItem = DataMapItem.fromDataItem(event.dataItem)
val asset = dataMapItem.dataMap.getAsset("gpx_file")
val fileName = path.substringAfterLast("/")
if (asset != null) {
scope.launch {
importGpxFromAsset(asset, fileName)
}
}
}
}
}
}
private suspend fun importGpxFromAsset(asset: Asset, fileName: String) {
try {
val fd = Wearable.getDataClient(this).getFdForAsset(asset).await()
val gpxContent = fd.inputStream.bufferedReader().use { it.readText() }
Log.d(TAG, "Received GPX from wear: $fileName")
val importer = GpxImporter(this)
val track = importer.importFromString(gpxContent)
if (track != null) {
val storage = TrackStorage(this)
val trackName = fileName.removeSuffix(".gpx").replace("_", " ")
val namedTrack = track.copy(name = trackName)
if (storage.getTrack(namedTrack.name) == null) {
storage.saveTrack(namedTrack)
Log.d(TAG, "Track saved: ${namedTrack.name}")
} else {
Log.d(TAG, "Track already exists: ${namedTrack.name}")
}
withContext(Dispatchers.Main) {
sendBroadcast(Intent(ACTION_TRACK_SYNCED))
}
} else {
Log.e(TAG, "Failed to parse GPX: $fileName")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to import asset: $fileName", e)
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}
@@ -0,0 +1,387 @@
package de.tstratmann.gpstracker
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import de.tstratmann.gpstracker.databinding.ActivityTrackDetailBinding
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.*
import android.widget.LinearLayout
import android.widget.TextView
class TrackDetailActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var binding: ActivityTrackDetailBinding
private lateinit var googleMap: GoogleMap
private lateinit var track: Track
private val waypointMarkers = mutableListOf<Marker>()
companion object {
const val EXTRA_TRACK_NAME = "track_name"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityTrackDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
val trackName = intent.getStringExtra(EXTRA_TRACK_NAME)
if (trackName == null) {
finish()
return
}
val trackStorage = TrackStorage(this)
track = trackStorage.getTrack(trackName) ?: run {
finish()
return
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = track.name.replace("_", " ")
val mapFragment = supportFragmentManager
.findFragmentById(R.id.mapDetail) as SupportMapFragment
mapFragment.getMapAsync(this)
displayTrackInfo()
setupMapTypeButton()
}
private fun setupMapTypeButton() {
binding.mapTypeButton.setOnClickListener {
toggleMapType()
}
}
private fun toggleMapType() {
googleMap.mapType = when (googleMap.mapType) {
GoogleMap.MAP_TYPE_NORMAL -> {
android.widget.Toast.makeText(this, "Satellit", android.widget.Toast.LENGTH_SHORT).show()
GoogleMap.MAP_TYPE_SATELLITE
}
GoogleMap.MAP_TYPE_SATELLITE -> {
android.widget.Toast.makeText(this, "Hybrid", android.widget.Toast.LENGTH_SHORT).show()
GoogleMap.MAP_TYPE_HYBRID
}
else -> {
android.widget.Toast.makeText(this, "Karte", android.widget.Toast.LENGTH_SHORT).show()
GoogleMap.MAP_TYPE_NORMAL
}
}
}
override fun onMapReady(map: GoogleMap) {
googleMap = map
googleMap.apply {
mapType = GoogleMap.MAP_TYPE_NORMAL
uiSettings.isZoomControlsEnabled = true
uiSettings.isCompassEnabled = true
}
drawTrackWithSpeedGradient()
addStartEndMarkers()
fitMapToTrack()
googleMap.setOnCameraIdleListener {
displayWaypoints(googleMap.cameraPosition.zoom)
}
googleMap.setOnMarkerClickListener { marker ->
val point = marker.tag as? TrackPoint ?: return@setOnMarkerClickListener false
marker.title = formatWaypointSpeed(point.speed)
marker.snippet = "Kurs: ${point.bearing.toInt()}°"
marker.showInfoWindow()
true
}
}
private fun getWaypointStride(zoom: Float): Int = when {
zoom < 12f -> Int.MAX_VALUE
zoom < 13f -> 100
zoom < 14f -> 50
zoom < 15f -> 20
zoom < 16f -> 10
zoom < 17f -> 5
zoom < 18f -> 3
else -> 1
}
private fun createArrowBitmap(bearing: Float): BitmapDescriptor {
val size = 48
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val cx = size / 2f
val cy = size / 2f
val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(220, 30, 100, 200)
style = Paint.Style.STROKE
strokeWidth = 3f
}
val path = Path().apply {
moveTo(cx, cy - size * 0.38f)
lineTo(cx + size * 0.22f, cy + size * 0.30f)
lineTo(cx, cy + size * 0.12f)
lineTo(cx - size * 0.22f, cy + size * 0.30f)
close()
}
canvas.rotate(bearing, cx, cy)
canvas.drawPath(path, fillPaint)
canvas.drawPath(path, strokePaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
private fun displayWaypoints(zoom: Float) {
waypointMarkers.forEach { it.remove() }
waypointMarkers.clear()
val stride = getWaypointStride(zoom)
if (stride == Int.MAX_VALUE) return
track.points.forEachIndexed { index, point ->
if (index % stride == 0) {
val marker = googleMap.addMarker(
MarkerOptions()
.position(LatLng(point.latitude, point.longitude))
.icon(createArrowBitmap(point.bearing))
.anchor(0.5f, 0.5f)
.flat(true)
) ?: return@forEachIndexed
marker.tag = point
waypointMarkers.add(marker)
}
}
}
private fun formatWaypointSpeed(speedMs: Float): String {
val useKnots = track.activityType?.useKnots == true
return if (useKnots) {
val knots = speedMs * 1.94384f
"%.1f kn".format(knots)
} else {
"${(speedMs * 3.6f).toInt()} km/h"
}
}
private fun drawTrackWithSpeedGradient() {
if (track.points.size < 2) return
val minSpeed = track.points.minOfOrNull { it.speed } ?: 0f
val maxSpeed = track.points.maxOfOrNull { it.speed } ?: 1f
val speedRange = maxSpeed - minSpeed
for (i in 0 until track.points.size - 1) {
val point1 = track.points[i]
val point2 = track.points[i + 1]
val avgSpeed = (point1.speed + point2.speed) / 2
val color = getSpeedColor(avgSpeed, minSpeed, maxSpeed, speedRange)
val polylineOptions = PolylineOptions()
.add(LatLng(point1.latitude, point1.longitude))
.add(LatLng(point2.latitude, point2.longitude))
.color(color)
.width(10f)
.geodesic(true)
googleMap.addPolyline(polylineOptions)
}
}
private fun getSpeedColor(speed: Float, minSpeed: Float, maxSpeed: Float, speedRange: Float): Int {
val normalized = if (speedRange > 0) {
((speed - minSpeed) / speedRange).coerceIn(0f, 1f)
} else {
0.5f
}
return when {
normalized < 0.33f -> {
val factor = normalized / 0.33f
interpolateColor(0xFF90EE90.toInt(), 0xFFFFFF00.toInt(), factor)
}
normalized < 0.66f -> {
val factor = (normalized - 0.33f) / 0.33f
interpolateColor(0xFFFFFF00.toInt(), 0xFFFFA500.toInt(), factor)
}
else -> {
val factor = (normalized - 0.66f) / 0.34f
interpolateColor(0xFFFFA500.toInt(), 0xFF8B0000.toInt(), factor)
}
}
}
private fun interpolateColor(colorStart: Int, colorEnd: Int, factor: Float): Int {
val a = (Color.alpha(colorStart) + factor * (Color.alpha(colorEnd) - Color.alpha(colorStart))).toInt()
val r = (Color.red(colorStart) + factor * (Color.red(colorEnd) - Color.red(colorStart))).toInt()
val g = (Color.green(colorStart) + factor * (Color.green(colorEnd) - Color.green(colorStart))).toInt()
val b = (Color.blue(colorStart) + factor * (Color.blue(colorEnd) - Color.blue(colorStart))).toInt()
return Color.argb(a, r, g, b)
}
private fun addStartEndMarkers() {
if (track.points.isEmpty()) return
val startPoint = track.points.first()
val endPoint = track.points.last()
googleMap.addMarker(
MarkerOptions()
.position(LatLng(startPoint.latitude, startPoint.longitude))
.title("Start")
.icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_GREEN))
)
if (track.points.size > 1) {
googleMap.addMarker(
MarkerOptions()
.position(LatLng(endPoint.latitude, endPoint.longitude))
.title("Ziel")
.icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED))
)
}
}
private fun fitMapToTrack() {
if (track.points.isEmpty()) return
val boundsBuilder = LatLngBounds.Builder()
track.points.forEach { point ->
boundsBuilder.include(LatLng(point.latitude, point.longitude))
}
try {
val bounds = boundsBuilder.build()
googleMap.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100))
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun displayTrackInfo() {
binding.trackNameText.text = track.name.replace("_", " ")
binding.statsContainer.removeAllViews()
val statsGrid = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
weightSum = 2f
}
val leftColumn = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
leftColumn.addView(createStatRow("Dauer", track.getFormattedDuration()))
leftColumn.addView(createStatRow("Distanz", track.getFormattedDistance()))
val rightColumn = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
rightColumn.addView(createStatRow("Ø Geschw.", "${track.getAverageSpeedKmh()} km/h"))
rightColumn.addView(createStatRow("Max. Geschw.", "${track.getMaxSpeedKmh()} km/h"))
statsGrid.addView(leftColumn)
statsGrid.addView(rightColumn)
binding.statsContainer.addView(statsGrid)
addSpeedLegend()
}
private fun createStatRow(label: String, value: String): LinearLayout {
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 0, 0, 24)
addView(TextView(context).apply {
text = label
textSize = 12f
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
})
addView(TextView(context).apply {
text = value
textSize = 18f
setTextColor(ContextCompat.getColor(context, android.R.color.black))
setTypeface(null, android.graphics.Typeface.BOLD)
})
}
}
private fun addSpeedLegend() {
val legendTitle = TextView(this).apply {
text = "Geschwindigkeitslegende"
textSize = 14f
setTextColor(ContextCompat.getColor(context, android.R.color.black))
setTypeface(null, android.graphics.Typeface.BOLD)
setPadding(0, 16, 0, 8)
}
binding.statsContainer.addView(legendTitle)
val legendBar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 40)
}
listOf(0xFF90EE90.toInt(), 0xFFFFFF00.toInt(), 0xFFFFA500.toInt(), 0xFF8B0000.toInt()).forEach { color ->
val segment = android.view.View(this).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
setBackgroundColor(color)
}
legendBar.addView(segment)
}
binding.statsContainer.addView(legendBar)
val legendLabels = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
setPadding(0, 4, 0, 0)
}
legendLabels.addView(TextView(this).apply {
text = "Langsam"
textSize = 11f
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
})
legendLabels.addView(TextView(this).apply {
text = "Schnell"
textSize = 11f
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
textAlignment = TextView.TEXT_ALIGNMENT_TEXT_END
})
binding.statsContainer.addView(legendLabels)
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
}
@@ -0,0 +1,89 @@
package de.tstratmann.gpstracker
import android.location.Location
import java.util.Date
data class TrackPoint(
val latitude: Double,
val longitude: Double,
val altitude: Double,
val speed: Float,
val timestamp: Long,
val accuracy: Float,
val bearing: Float = 0f
) {
companion object {
fun fromLocation(location: Location): TrackPoint {
return TrackPoint(
latitude = location.latitude,
longitude = location.longitude,
altitude = location.altitude,
speed = location.speed,
timestamp = location.time,
accuracy = location.accuracy,
bearing = location.bearing
)
}
}
fun getDate(): Date {
return Date(timestamp)
}
}
data class Track(
val id: Long = System.currentTimeMillis(),
val name: String,
val activityType: ActivityType? = null,
val points: List<TrackPoint>,
val totalDistance: Double,
val createdAt: Long = System.currentTimeMillis()
) {
val timestamp: Long get() = createdAt
fun getActivityTypeOrDefault(): ActivityType = activityType ?: ActivityType.WING_FOILING
fun getFormattedDistance(): String {
return "%.2f km".format(totalDistance)
}
fun getPointsCount(): Int {
return points.size
}
fun getDurationMillis(): Long {
if (points.size < 2) return 0
return points.last().timestamp - points.first().timestamp
}
fun getFormattedDuration(): String {
val durationMillis = getDurationMillis()
val seconds = (durationMillis / 1000) % 60
val minutes = (durationMillis / (1000 * 60)) % 60
val hours = (durationMillis / (1000 * 60 * 60))
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
fun getAverageSpeed(): Float {
if (points.isEmpty()) return 0f
val speeds = points.map { it.speed }.filter { it > 0 }
return if (speeds.isEmpty()) 0f else speeds.average().toFloat()
}
fun getAverageSpeedKmh(): Int {
return LocationHelper.metersPerSecondToKmh(getAverageSpeed())
}
fun getMaxSpeed(): Float {
if (points.isEmpty()) return 0f
return points.maxOfOrNull { it.speed } ?: 0f
}
fun getMaxSpeedKmh(): Int {
return LocationHelper.metersPerSecondToKmh(getMaxSpeed())
}
fun getFormattedAverageSpeed(): String = getActivityTypeOrDefault().formatSpeed(getAverageSpeed())
fun getFormattedMaxSpeed(): String = getActivityTypeOrDefault().formatSpeed(getMaxSpeed())
}
@@ -0,0 +1,130 @@
package de.tstratmann.gpstracker
import android.content.Context
import com.google.gson.Gson
import java.io.File
class TrackStorage(private val context: Context) {
private val gson = Gson()
companion object {
private const val TRACKS_DIR = "tracks"
private const val FILE_EXTENSION = ".json"
}
private fun getTracksDirectory(): File {
val baseDir = context.getExternalFilesDir(null)
?: context.filesDir
val tracksDir = File(baseDir, TRACKS_DIR)
if (!tracksDir.exists()) {
tracksDir.mkdirs()
}
return tracksDir
}
fun saveTrack(track: Track) {
try {
val fileName = sanitizeFileName(track.name) + FILE_EXTENSION
val file = File(getTracksDirectory(), fileName)
val json = gson.toJson(track)
file.writeText(json)
android.util.Log.d("TrackStorage", "Track saved to: ${file.absolutePath}")
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error saving track", e)
}
}
fun getAllTracks(): List<Track> {
return try {
val tracksDir = getTracksDirectory()
val trackFiles = tracksDir.listFiles { file ->
file.extension == "json"
} ?: emptyArray()
trackFiles.mapNotNull { file ->
try {
val json = file.readText()
gson.fromJson(json, Track::class.java)
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error reading track: ${file.name}", e)
null
}
}.sortedByDescending { it.timestamp }
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error getting all tracks", e)
emptyList()
}
}
fun deleteTrack(trackName: String) {
try {
val fileName = sanitizeFileName(trackName) + FILE_EXTENSION
val file = File(getTracksDirectory(), fileName)
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error deleting track", e)
}
}
fun deleteTrackById(trackId: Long) {
try {
val tracks = getAllTracks()
val track = tracks.find { it.id == trackId }
track?.let { deleteTrack(it.name) }
} catch (e: Exception) {
e.printStackTrace()
}
}
fun clearAllTracks() {
try {
val tracksDir = getTracksDirectory()
tracksDir.listFiles()?.forEach { it.delete() }
} catch (e: Exception) {
e.printStackTrace()
}
}
fun getTrackCount(): Int {
return try {
val tracksDir = getTracksDirectory()
tracksDir.listFiles { file -> file.extension == "json" }?.size ?: 0
} catch (e: Exception) {
0
}
}
fun getTrack(trackName: String): Track? {
return try {
val fileName = sanitizeFileName(trackName) + FILE_EXTENSION
val file = File(getTracksDirectory(), fileName)
if (file.exists()) {
val json = file.readText()
gson.fromJson(json, Track::class.java)
} else {
null
}
} catch (e: Exception) {
null
}
}
fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9.-_]"), "_")
}
fun getStoragePath(): String {
return getTracksDirectory().absolutePath
}
}
@@ -0,0 +1,177 @@
package de.tstratmann.gpstracker
import android.app.*
import android.content.Context
import android.content.Intent
import android.location.Location
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.*
class TrackingService : Service() {
private val binder = TrackingBinder()
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var locationCallback: LocationCallback
private val trackPoints = mutableListOf<TrackPoint>()
private var totalDistance = 0.0
private var lastLocation: Location? = null
private var isTracking = false
private var currentActivityType: ActivityType = ActivityType.WING_FOILING
var onLocationUpdate: ((TrackPoint, Double) -> Unit)? = null
companion object {
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "gps_tracking_channel"
}
fun getActivityType(): ActivityType = currentActivityType
inner class TrackingBinder : Binder() {
fun getService(): TrackingService = this@TrackingService
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onCreate() {
super.onCreate()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
createNotificationChannel()
setupLocationCallback()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, createNotification())
return START_STICKY
}
fun startTracking(activityType: ActivityType = ActivityType.WING_FOILING) {
if (isTracking) return
currentActivityType = activityType
isTracking = true
trackPoints.clear()
totalDistance = 0.0
lastLocation = null
try {
val locationRequest = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY,
activityType.updateIntervalMs
).apply {
setMinUpdateIntervalMillis(activityType.updateIntervalMs)
setMaxUpdateDelayMillis(activityType.updateIntervalMs * 2)
setMinUpdateDistanceMeters(activityType.minDistanceM)
}.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper()
)
updateNotification("${activityType.label} aktiv...")
} catch (e: SecurityException) {
e.printStackTrace()
}
}
fun stopTracking() {
isTracking = false
fusedLocationClient.removeLocationUpdates(locationCallback)
updateNotification("Tracking gestoppt")
}
fun getTrackPoints(): List<TrackPoint> = trackPoints.toList()
fun getTotalDistance(): Double = totalDistance
fun clearTrack() {
trackPoints.clear()
totalDistance = 0.0
lastLocation = null
}
private fun setupLocationCallback() {
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
handleLocationUpdate(location)
}
}
}
}
private fun handleLocationUpdate(location: Location) {
val trackPoint = TrackPoint.fromLocation(location)
trackPoints.add(trackPoint)
lastLocation?.let { last ->
val distance = LocationHelper.calculateDistance(
last.latitude, last.longitude,
location.latitude, location.longitude
)
totalDistance += distance
}
lastLocation = location
val speedFormatted = currentActivityType.formatSpeed(location.speed)
updateNotification(
"Distanz: ${"%.2f".format(totalDistance)} km | " +
"Geschw.: $speedFormatted"
)
onLocationUpdate?.invoke(trackPoint, totalDistance)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"GPS Tracking",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Zeigt GPS Tracking Status an"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(text: String = "GPS Tracker läuft"): Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("GPS Tracker")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val notification = createNotification(text)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, notification)
}
override fun onDestroy() {
super.onDestroy()
stopTracking()
}
}
@@ -0,0 +1,99 @@
package de.tstratmann.gpstracker
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
data class WindData(
val speed: Float,
val direction: Int,
val isAvailable: Boolean = true
) {
companion object {
fun unavailable(): WindData {
return WindData(0f, 0, false)
}
}
fun getFormattedSpeed(): String {
return if (isAvailable) "${speed.toInt()} km/h" else "N/A"
}
fun getFormattedDirection(): String {
return if (isAvailable) "$direction°" else "N/A"
}
fun getCompassDirection(): String {
if (!isAvailable) return "N/A"
return when {
direction < 22.5 || direction >= 337.5 -> "N"
direction < 67.5 -> "NO"
direction < 112.5 -> "O"
direction < 157.5 -> "SO"
direction < 202.5 -> "S"
direction < 247.5 -> "SW"
direction < 292.5 -> "W"
direction < 337.5 -> "NW"
else -> "N/A"
}
}
}
class WindDataClient {
companion object {
private const val TAG = "WindDataClient"
private const val API_URL = "https://api.open-meteo.com/v1/forecast"
}
suspend fun fetchWindData(latitude: Double, longitude: Double): WindData? {
return withContext(Dispatchers.IO) {
try {
val urlString = "$API_URL?latitude=$latitude&longitude=$longitude&current=wind_speed_10m,wind_direction_10m"
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.apply {
requestMethod = "GET"
connectTimeout = 5000
readTimeout = 5000
}
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().use { it.readText() }
parseWindData(response)
} else {
Log.e(TAG, "API returned error code: $responseCode")
null
}
} catch (e: Exception) {
Log.e(TAG, "Error fetching wind data", e)
null
}
}
}
private fun parseWindData(jsonResponse: String): WindData? {
return try {
val json = JSONObject(jsonResponse)
val current = json.getJSONObject("current")
val windSpeed = current.getDouble("wind_speed_10m").toFloat()
val windDirection = current.getInt("wind_direction_10m")
WindData(
speed = windSpeed,
direction = windDirection,
isAvailable = true
)
} catch (e: Exception) {
Log.e(TAG, "Error parsing wind data", e)
null
}
}
}
@@ -1,20 +0,0 @@
package de.tstratmann.gpstracker_wear
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}
@@ -1,54 +0,0 @@
package de.tstratmann.gpstracker_wear
import android.util.Log
import com.google.android.gms.wearable.*
import kotlinx.coroutines.*
import kotlinx.coroutines.tasks.await
import java.io.File
import java.io.FileOutputStream
class MobileSyncService : WearableListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onDataChanged(dataEvents: DataEventBuffer) {
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
val path = event.dataItem.uri.path
if (path?.startsWith("/gpx_export/") == true) {
val dataMapItem = DataMapItem.fromDataItem(event.dataItem)
val asset = dataMapItem.dataMap.getAsset("gpx_file")
val fileName = path.substringAfterLast("/")
if (asset != null) {
scope.launch {
saveAssetToFile(asset, fileName)
}
}
}
}
}
}
private suspend fun saveAssetToFile(asset: Asset, fileName: String) {
try {
val fd = Wearable.getDataClient(this).getFdForAsset(asset).await()
val inputStream = fd.inputStream
val file = File(getExternalFilesDir(null), fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
Log.d("MobileSyncService", "File saved: ${file.absolutePath}")
// Hier könnte man einen Broadcast senden, um die UI zu benachrichtigen
} catch (e: Exception) {
Log.e("MobileSyncService", "Failed to save asset", e)
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Arrow shaft -->
<path
android:fillColor="#FF5722"
android:pathData="M11,3 L13,3 L13,18 L11,18 Z" />
<!-- Arrow head (triangle pointing up) -->
<path
android:fillColor="#FF5722"
android:pathData="M12,0 L18,8 L6,8 Z" />
<!-- Arrow tail (small wings) -->
<path
android:fillColor="#FF5722"
android:pathData="M8,20 L12,18 L16,20 L12,22 Z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFF" />
<size
android:width="56dp"
android:height="56dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E8F4FD" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F5F5">
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Gespeicherte Tracks"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="16dp" />
<LinearLayout
android:id="@+id/tracksContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:id="@+id/storageInfoText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0 Track(s) gespeichert"
android:textSize="11sp"
android:textColor="@android:color/darker_gray"
android:layout_marginTop="16dp"
android:padding="8dp" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
+357 -6
View File
@@ -1,19 +1,370 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
<!-- Map Fragment - 60% of screen -->
<fragment
android:id="@+id/map"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/bottomContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_weight="60" />
<!-- Status Badge -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/statusCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:layout_margin="16dp"
app:cardCornerRadius="20dp"
app:cardElevation="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
android:text="Bereit"
android:textColor="@android:color/black"
android:textSize="14sp"
android:textStyle="bold" />
</com.google.android.material.card.MaterialCardView>
<!-- Wind Arrow Indicator -->
<ImageView
android:id="@+id/windArrow"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_margin="16dp"
android:padding="12dp"
android:src="@drawable/ic_wind_arrow"
android:background="@drawable/white_circle_background"
android:elevation="6dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/statusCard" />
<!-- Map Type Toggle Button -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/mapTypeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_mapmode"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/bottomContainer"
app:backgroundTint="#FFFFFF"
app:tint="@android:color/black"
app:fabSize="normal" />
<!-- Bottom Container - 40% of screen -->
<LinearLayout
android:id="@+id/bottomContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@+id/map"
app:layout_constraintVertical_weight="40">
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Stats Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/statsCard"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_margin="0dp"
app:cardCornerRadius="0dp"
app:cardElevation="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp"
android:gravity="center">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Row 1: Duration and Distance -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DAUER"
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/durationText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00:00"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DISTANZ"
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/distanceText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.00 km"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- Row 2: Speed and Heading -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="GESCHW."
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/speedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0 km/h"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RICHTUNG"
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/headingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0° Grad"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="6dp"
android:background="#DDEAF4" />
<!-- Wind Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/wind_section_background"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="6dp"
android:paddingBottom="6dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2"
android:layout_marginBottom="2dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WIND"
android:textColor="#5B87A8"
android:textSize="9sp" />
<TextView
android:id="@+id/windSpeedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="N/A"
android:textColor="#1A5276"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WIND RICHTUNG"
android:textColor="#5B87A8"
android:textSize="9sp" />
<TextView
android:id="@+id/windDirectionText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="N/A"
android:textColor="#1A5276"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingTop="2dp">
<TextView
android:id="@+id/windLocationText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lokation: --"
android:textColor="#5B87A8"
android:textSize="10sp" />
<ImageButton
android:id="@+id/windWebButton"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_info_details"
android:scaleType="fitCenter"
android:contentDescription="Wind-Daten Webseite öffnen" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Controls Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/controlsCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
app:cardCornerRadius="0dp"
app:cardElevation="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/startStopButton"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="1"
android:layout_marginEnd="6dp"
android:backgroundTint="#4CAF50"
android:text="Start"
android:textSize="14sp"
app:cornerRadius="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/activitiesButton"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:backgroundTint="#9C27B0"
android:text="Aktivitäten"
android:textSize="14sp"
android:visibility="visible"
app:cornerRadius="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/mapDetail"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/infoCard"
app:layout_constraintHeight_percent="0.5" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/mapTypeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_mapmode"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/infoCard"
app:backgroundTint="#FFFFFF"
app:tint="@android:color/black"
app:fabSize="normal" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/infoCard"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="0dp"
app:cardCornerRadius="0dp"
app:cardElevation="8dp"
app:layout_constraintTop_toBottomOf="@+id/mapDetail"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/trackNameText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Track Name"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="24dp" />
<LinearLayout
android:id="@+id/statsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4.dp"
app:cardCornerRadius="8.dp"
app:cardElevation="2.dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12.dp">
<TextView
android:id="@+id/trackName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Track Name"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textStyle="bold" />
<TextView
android:id="@+id/trackInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4.dp"
android:text="Datum / Größe"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" />
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_drive_backup"
android:title="Google Drive Backup"
android:icon="@android:drawable/ic_menu_save"
app:showAsAction="never" />
<item
android:id="@+id/action_drive_restore"
android:title="Google Drive Restore"
android:icon="@android:drawable/ic_menu_upload"
app:showAsAction="never" />
<item
android:id="@+id/action_import_gpx"
android:title="GPX importieren"
android:icon="@android:drawable/ic_menu_add"
app:showAsAction="never" />
</menu>
+6 -5
View File
@@ -1,7 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.GPSTracker_Wear" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
<!-- Dark theme override - inherits from Theme.GPSTracker -->
<style name="Theme.GPSTracker" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
</style>
</resources>
</resources>
+5 -1
View File
@@ -2,4 +2,8 @@
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
<color name="primary">#4CAF50</color>
<color name="primary_dark">#388E3C</color>
<color name="accent">#2196F3</color>
<color name="ic_launcher_background">#4CAF50</color>
</resources>
+9 -2
View File
@@ -1,3 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">GPSTracker_Wear</string>
</resources>
<string name="app_name">GPS Tracker</string>
<string name="start_tracking">Start Aufzeichnung</string>
<string name="stop_tracking">Stop Aufzeichnung</string>
<string name="export_gpx">GPX Export</string>
<string name="status_ready">Bereit</string>
<string name="status_recording">Aufzeichnung läuft</string>
<string name="status_stopped">Gestoppt</string>
</resources>
+7 -8
View File
@@ -1,9 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.GPSTracker_Wear" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GPSTracker" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
</style>
<style name="Theme.GPSTracker_Wear" parent="Base.Theme.GPSTracker_Wear" />
</resources>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="gpx_files" path="/" />
</paths>
+4 -3
View File
@@ -4,11 +4,11 @@ plugins {
}
android {
namespace = "de.tstratmann.gpstracker_wear"
namespace = "de.tstratmann.gpstracker"
compileSdk = 36
defaultConfig {
applicationId = "de.tstratmann.gpstracker_wear"
applicationId = "de.tstratmann.gpstracker"
minSdk = 30
targetSdk = 36
versionCode = 1
@@ -31,6 +31,7 @@ android {
buildFeatures {
compose = true
}
}
dependencies {
@@ -49,4 +50,4 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
}
}
@@ -1,4 +1,4 @@
package de.tstratmann.gpstracker_wear
package de.tstratmann.gpstracker
import android.content.Context
import android.location.Location
@@ -19,23 +19,30 @@ class GPXManager(private val context: Context) {
return file
}
fun finalizeSession() {
currentFile?.appendText(getGpxFooter())
currentFile = null
}
fun appendLocation(location: Location) {
currentFile?.let { file ->
val time = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(location.time))
val trkpt = """
<trkpt lat="${location.latitude}" lon="${location.longitude}">
<ele>${location.altitude}</ele>
<time>$time</time>
</trkpt>
""".trimIndent()
// This is a simple append, for a real app we'd want to handle the footer properly
// But for this exercise, we'll strip the footer, append, then re-add footer
val content = file.readText().removeSuffix(getGpxFooter())
file.writeText(content + trkpt + getGpxFooter())
val sb = StringBuilder()
sb.append("<trkpt lat=\"${location.latitude}\" lon=\"${location.longitude}\">")
sb.append("<ele>${location.altitude}</ele>")
sb.append("<time>$time</time>")
if (location.speed > 0 || location.bearing > 0) {
sb.append("<extensions>")
if (location.speed > 0) sb.append("<speed>${location.speed}</speed>")
if (location.bearing > 0) sb.append("<course>${location.bearing}</course>")
sb.append("</extensions>")
}
sb.append("</trkpt>")
file.appendText(sb.toString())
}
}
@@ -47,6 +54,12 @@ class GPXManager(private val context: Context) {
return context.filesDir.listFiles { _, name -> name.endsWith(".gpx") }?.toList() ?: emptyList()
}
fun deleteAllFiles(): Int {
val files = getAllFiles()
files.forEach { it.delete() }
return files.size
}
private fun getGpxHeader() = """
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="WearGPSTracker" xmlns="http://www.topografix.com/GPX/1/1">
@@ -1,4 +1,4 @@
package de.tstratmann.gpstracker_wear
package de.tstratmann.gpstracker
import android.app.*
import android.content.Intent
@@ -11,7 +11,6 @@ import com.google.android.gms.location.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.*
class TrackingService : Service() {
@@ -26,11 +25,14 @@ class TrackingService : Service() {
private var startTime = 0L
private var lastLocation: Location? = null
private var job: Job? = null
private var speedSumKmh = 0.0
private var speedCount = 0
data class TrackingData(
val durationSeconds: Long = 0,
val distanceKm: Double = 0.0,
val speedKmh: Double = 0.0
val speedKmh: Double = 0.0,
val avgSpeedKmh: Double = 0.0
)
inner class LocalBinder : Binder() {
@@ -41,9 +43,9 @@ class TrackingService : Service() {
super.onCreate()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
gpxManager = GPXManager(this)
createNotificationChannel()
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
@@ -55,20 +57,27 @@ class TrackingService : Service() {
private fun processLocation(location: Location) {
gpxManager.appendLocation(location)
val currentData = _trackingState.value
var newDistance = currentData.distanceKm
lastLocation?.let { last ->
newDistance += last.distanceTo(location) / 1000.0
}
lastLocation = location
val speedKmh = location.speed * 3.6 // m/s to km/h
val speedKmh = location.speed * 3.6
if (speedKmh > 0) {
speedSumKmh += speedKmh
speedCount++
}
val avgSpeedKmh = if (speedCount > 0) speedSumKmh / speedCount else 0.0
_trackingState.value = currentData.copy(
distanceKm = newDistance,
speedKmh = speedKmh
speedKmh = speedKmh,
avgSpeedKmh = avgSpeedKmh
)
}
@@ -87,10 +96,13 @@ class TrackingService : Service() {
gpxManager.startNewSession()
startTime = System.currentTimeMillis()
lastLocation = null
speedSumKmh = 0.0
speedCount = 0
_trackingState.value = TrackingData()
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
.setMinUpdateIntervalMillis(1000)
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
.setMinUpdateIntervalMillis(5000)
.setMinUpdateDistanceMeters(10f)
.build()
try {
@@ -111,6 +123,7 @@ class TrackingService : Service() {
fun stopTracking() {
fusedLocationClient.removeLocationUpdates(locationCallback)
job?.cancel()
gpxManager.finalizeSession()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -1,7 +1,6 @@
package de.tstratmann.gpstracker_wear
package de.tstratmann.gpstracker
import android.content.Context
import android.net.Uri
import android.util.Log
import com.google.android.gms.wearable.Asset
import com.google.android.gms.wearable.PutDataMapRequest
@@ -15,12 +14,12 @@ class WearSyncManager(private val context: Context) {
return try {
val bytes = file.readBytes()
val asset = Asset.createFromBytes(bytes)
val request = PutDataMapRequest.create("/gpx_export/${file.name}").apply {
dataMap.putAsset("gpx_file", asset)
dataMap.putLong("timestamp", System.currentTimeMillis())
}
val putDataRequest = request.asPutDataRequest().setUrgent()
Wearable.getDataClient(context).putDataItem(putDataRequest).await()
Log.d("WearSyncManager", "File synced: ${file.name}")
@@ -30,4 +29,4 @@ class WearSyncManager(private val context: Context) {
false
}
}
}
}
@@ -1,4 +1,4 @@
package de.tstratmann.gpstracker_wear.presentation
package de.tstratmann.gpstracker.presentation
import android.Manifest
import android.content.ComponentName
@@ -14,6 +14,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -28,12 +30,11 @@ import androidx.wear.compose.material.*
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import de.tstratmann.gpstracker_wear.GPXManager
import de.tstratmann.gpstracker_wear.TrackingService
import de.tstratmann.gpstracker_wear.WearSyncManager
import de.tstratmann.gpstracker_wear.presentation.theme.GPSTracker_WearTheme
import de.tstratmann.gpstracker.GPXManager
import de.tstratmann.gpstracker.TrackingService
import de.tstratmann.gpstracker.WearSyncManager
import de.tstratmann.gpstracker.presentation.theme.GPSTrackerTheme
import kotlinx.coroutines.launch
import java.util.*
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -47,7 +48,7 @@ class MainActivity : ComponentActivity() {
@Composable
fun WearApp() {
val navController = rememberSwipeDismissableNavController()
GPSTracker_WearTheme {
GPSTrackerTheme {
Scaffold(
timeText = { TimeText() }
) {
@@ -155,19 +156,21 @@ fun TrackingScreen(navController: NavHostController) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 16.dp)
) {
val hours = trackingData.durationSeconds / 3600
val minutes = (trackingData.durationSeconds % 3600) / 60
val seconds = trackingData.durationSeconds % 60
val timeString = String.format("%02d:%02d:%02d", hours, minutes, seconds)
Text(text = timeString, style = MaterialTheme.typography.display1)
Text(text = timeString, style = MaterialTheme.typography.title1)
Text(text = String.format("%.2f km", trackingData.distanceKm), style = MaterialTheme.typography.body1)
Text(text = String.format("%.1f km/h", trackingData.speedKmh), style = MaterialTheme.typography.body1)
Text(text = String.format("Ø %.1f km/h", trackingData.avgSpeedKmh), style = MaterialTheme.typography.body2)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
trackingService?.stopTracking()
@@ -184,7 +187,7 @@ fun TrackingScreen(navController: NavHostController) {
}
}
}
DisposableEffect(Unit) {
onDispose {
if (isBound) {
@@ -201,16 +204,52 @@ fun ActivitiesScreen(navController: NavHostController) {
val gpxManager = remember { GPXManager(context) }
val syncManager = remember { WearSyncManager(context) }
var count by remember { mutableIntStateOf(gpxManager.getActivitiesCount()) }
var showDeleteConfirm by remember { mutableStateOf(false) }
if (showDeleteConfirm) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Alle löschen?", style = MaterialTheme.typography.title3)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val deleted = gpxManager.deleteAllFiles()
count = 0
showDeleteConfirm = false
Toast.makeText(context, "$deleted Aktivitäten gelöscht", Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red),
modifier = Modifier.fillMaxWidth(0.8f)
) {
Text("Ja, löschen", color = Color.White)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showDeleteConfirm = false },
modifier = Modifier.fillMaxWidth(0.8f),
colors = ButtonDefaults.secondaryButtonColors()
) {
Text("Abbrechen")
}
}
}
return
}
val scrollState = rememberScrollState()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
modifier = Modifier.verticalScroll(scrollState).padding(vertical = 16.dp)
) {
Text(text = "$count Aktivitäten", style = MaterialTheme.typography.title3)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
onClick = {
val files = gpxManager.getAllFiles()
if (files.isNotEmpty()) {
scope.launch {
@@ -231,6 +270,14 @@ fun ActivitiesScreen(navController: NavHostController) {
Text("GPX Sync")
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(0.8f),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red)
) {
Text("Alle löschen", color = Color.White)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { navController.popBackStack() },
modifier = Modifier.fillMaxWidth(0.8f),
@@ -240,4 +287,4 @@ fun ActivitiesScreen(navController: NavHostController) {
}
}
}
}
}
@@ -0,0 +1,13 @@
package de.tstratmann.gpstracker.presentation.theme
import androidx.compose.runtime.Composable
import androidx.wear.compose.material.MaterialTheme
@Composable
fun GPSTrackerTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
content = content
)
}
@@ -1,17 +0,0 @@
package de.tstratmann.gpstracker_wear.presentation.theme
import androidx.compose.runtime.Composable
import androidx.wear.compose.material.MaterialTheme
@Composable
fun GPSTracker_WearTheme(
content: @Composable () -> Unit
) {
/**
* Empty theme to customize for your app.
* See: https://developer.android.com/jetpack/compose/designsystems/custom
*/
MaterialTheme(
content = content
)
}