diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..ec36e06
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Read(//c/Users/thors/AppData/Local/Android/**)"
+ ]
+ }
+}
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 552193f..c512029 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,16 +4,16 @@
-
+
-
+
-
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 74dd639..b2c751a 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/build.gradle.kts b/build.gradle.kts
index 952b930..18318be 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -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
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 64250b9..f291fc4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts
index 9921f82..86a1fba 100644
--- a/mobile/build.gradle.kts
+++ b/mobile/build.gradle.kts
@@ -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)
-}
\ No newline at end of file
+}
diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml
index 8cc5149..5c4d8af 100644
--- a/mobile/src/main/AndroidManifest.xml
+++ b/mobile/src/main/AndroidManifest.xml
@@ -2,6 +2,18 @@
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.GPSTracker">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
-
\ No newline at end of file
+
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/ActivitiesActivity.kt b/mobile/src/main/java/de/tstratmann/gpstracker/ActivitiesActivity.kt
new file mode 100644
index 0000000..0c14fea
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/ActivitiesActivity.kt
@@ -0,0 +1,400 @@
+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
+
+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) }
+ }
+
+ private val backupFolderLauncher = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) { uri ->
+ uri?.let { performDriveBackup(it) }
+ }
+
+ private val restoreFolderLauncher = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) { 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 -> {
+ backupFolderLauncher.launch(googleDriveUri())
+ true
+ }
+ R.id.action_drive_restore -> {
+ restoreFolderLauncher.launch(googleDriveUri())
+ true
+ }
+ R.id.action_import_gpx -> {
+ importGpxLauncher.launch("*/*")
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun googleDriveUri(): Uri =
+ Uri.parse("content://com.google.android.apps.docs.storage/")
+
+ private fun performDriveBackup(folderUri: 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 {
+ val folder = DocumentFile.fromTreeUri(this@ActivitiesActivity, folderUri)
+ if (folder == null || !folder.canWrite()) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@ActivitiesActivity, "Ordner nicht beschreibbar", Toast.LENGTH_LONG).show()
+ }
+ return@launch
+ }
+
+ var count = 0
+ tracks.forEach { track ->
+ val safeName = track.name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+
+ val jsonName = "$safeName.json"
+ folder.findFile(jsonName)?.delete()
+ folder.createFile("application/json", jsonName)?.let { doc ->
+ contentResolver.openOutputStream(doc.uri)?.use { os ->
+ os.write(gson.toJson(track).toByteArray(Charsets.UTF_8))
+ }
+ }
+
+ val gpxName = "$safeName.gpx"
+ folder.findFile(gpxName)?.delete()
+ folder.createFile("application/gpx+xml", gpxName)?.let { doc ->
+ contentResolver.openOutputStream(doc.uri)?.use { os ->
+ os.write(gpxExporter.generateGpxContent(track).toByteArray(Charsets.UTF_8))
+ }
+ }
+
+ count++
+ }
+
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@ActivitiesActivity, "$count Track(s) gesichert (JSON + GPX)", 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(folderUri: Uri) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val folder = DocumentFile.fromTreeUri(this@ActivitiesActivity, folderUri)
+ if (folder == null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@ActivitiesActivity, "Ordner nicht zugänglich", Toast.LENGTH_LONG).show()
+ }
+ return@launch
+ }
+
+ val files = folder.listFiles()
+ val jsonFiles = files.filter { it.name?.endsWith(".json") == true }
+ val gpxFiles = files.filter { it.name?.endsWith(".gpx") == true }
+ val jsonNames = jsonFiles.map { it.name!!.removeSuffix(".json") }.toSet()
+
+ var restored = 0
+ var skipped = 0
+
+ jsonFiles.forEach { file ->
+ contentResolver.openInputStream(file.uri)?.use { inputStream ->
+ val content = inputStream.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++
+ }
+ }
+ }
+
+ gpxFiles.filter { it.name?.removeSuffix(".gpx") !in jsonNames }.forEach { file ->
+ val track = gpxImporter.importFromUri(file.uri)
+ if (track != null) {
+ if (trackStorage.getTrack(track.name) == null) { trackStorage.saveTrack(track); restored++ } else skipped++
+ }
+ }
+
+ 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
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/ActivityType.kt b/mobile/src/main/java/de/tstratmann/gpstracker/ActivityType.kt
new file mode 100644
index 0000000..745c97b
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/ActivityType.kt
@@ -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"
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/GpxExporter.kt b/mobile/src/main/java/de/tstratmann/gpstracker/GpxExporter.kt
new file mode 100644
index 0000000..8cbb53f
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/GpxExporter.kt
@@ -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("\n")
+ builder.append("\n")
+
+ builder.append(" \n")
+ builder.append(" ${escapeXml(track.name)}\n")
+ builder.append(" \n")
+ builder.append(" \n")
+
+ builder.append(" \n")
+ builder.append(" ${escapeXml(track.name)}\n")
+ builder.append(" \n")
+
+ for (point in track.points) {
+ builder.append(" \n")
+ builder.append(" ${point.altitude}\n")
+ builder.append(" \n")
+
+ if (point.speed > 0 || point.bearing > 0) {
+ builder.append(" \n")
+ if (point.speed > 0) builder.append(" ${point.speed}\n")
+ if (point.bearing > 0) builder.append(" ${point.bearing}\n")
+ builder.append(" \n")
+ }
+
+ builder.append(" \n")
+ }
+
+ builder.append(" \n")
+ builder.append(" \n")
+ builder.append("")
+
+ 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("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'")
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/GpxImporter.kt b/mobile/src/main/java/de/tstratmann/gpstracker/GpxImporter.kt
new file mode 100644
index 0000000..2396aa4
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/GpxImporter.kt
@@ -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()
+ 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
+ }
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/LocationHelper.kt b/mobile/src/main/java/de/tstratmann/gpstracker/LocationHelper.kt
new file mode 100644
index 0000000..c2c4d0e
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/LocationHelper.kt
@@ -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): 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)
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/MainActivity.kt b/mobile/src/main/java/de/tstratmann/gpstracker/MainActivity.kt
new file mode 100644
index 0000000..4caa2a3
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/MainActivity.kt
@@ -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()
+
+ 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¤t=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
+ }
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/MobileSyncService.kt b/mobile/src/main/java/de/tstratmann/gpstracker/MobileSyncService.kt
new file mode 100644
index 0000000..bde6d64
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/MobileSyncService.kt
@@ -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()
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/TrackDetailActivity.kt b/mobile/src/main/java/de/tstratmann/gpstracker/TrackDetailActivity.kt
new file mode 100644
index 0000000..ec57c90
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/TrackDetailActivity.kt
@@ -0,0 +1,295 @@
+package de.tstratmann.gpstracker
+
+import android.graphics.Color
+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
+
+ 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()
+ }
+
+ 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
+ }
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/TrackPoint.kt b/mobile/src/main/java/de/tstratmann/gpstracker/TrackPoint.kt
new file mode 100644
index 0000000..aa69ad3
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/TrackPoint.kt
@@ -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,
+ 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())
+}
diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/TrackStorage.kt b/mobile/src/main/java/de/tstratmann/gpstracker/TrackStorage.kt
new file mode 100644
index 0000000..357e1a3
--- /dev/null
+++ b/mobile/src/main/java/de/tstratmann/gpstracker/TrackStorage.kt
@@ -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