From 3d2d68d1456cbf70b3243770f0b175f1f686d129 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Fri, 6 Mar 2026 08:20:54 +0100 Subject: [PATCH] wear und applaufen, allerdings wear ohne kmh --- .claude/settings.json | 7 + .idea/deploymentTargetSelector.xml | 6 +- .idea/misc.xml | 1 - build.gradle.kts | 1 - gradle/libs.versions.toml | 8 + mobile/build.gradle.kts | 36 +- mobile/src/main/AndroidManifest.xml | 65 ++- .../gpstracker/ActivitiesActivity.kt | 400 ++++++++++++++ .../de/tstratmann/gpstracker/ActivityType.kt | 19 + .../de/tstratmann/gpstracker/GpxExporter.kt | 131 +++++ .../de/tstratmann/gpstracker/GpxImporter.kt | 155 ++++++ .../tstratmann/gpstracker/LocationHelper.kt | 51 ++ .../de/tstratmann/gpstracker/MainActivity.kt | 513 ++++++++++++++++++ .../gpstracker/MobileSyncService.kt | 74 +++ .../gpstracker/TrackDetailActivity.kt | 295 ++++++++++ .../de/tstratmann/gpstracker/TrackPoint.kt | 89 +++ .../de/tstratmann/gpstracker/TrackStorage.kt | 130 +++++ .../tstratmann/gpstracker/TrackingService.kt | 177 ++++++ .../java/de/tstratmann/gpstracker/WindData.kt | 99 ++++ .../gpstracker_wear/MainActivity.kt | 20 - .../gpstracker_wear/MobileSyncService.kt | 54 -- .../src/main/res/drawable/ic_wind_arrow.xml | 22 + .../res/drawable/white_circle_background.xml | 8 + .../res/drawable/wind_section_background.xml | 6 + .../main/res/layout/activity_activities.xml | 51 ++ mobile/src/main/res/layout/activity_main.xml | 363 ++++++++++++- .../main/res/layout/activity_track_detail.xml | 73 +++ mobile/src/main/res/menu/menu_activities.xml | 22 + mobile/src/main/res/values-night/themes.xml | 11 +- mobile/src/main/res/values/colors.xml | 6 +- mobile/src/main/res/values/strings.xml | 11 +- mobile/src/main/res/values/themes.xml | 15 +- mobile/src/main/res/xml/file_paths.xml | 4 + wear/build.gradle.kts | 7 +- .../GPXManager.kt | 8 +- .../TrackingService.kt | 19 +- .../WearSyncManager.kt | 9 +- .../presentation/MainActivity.kt | 23 +- .../gpstracker/presentation/theme/Theme.kt | 13 + .../presentation/theme/Theme.kt | 17 - 40 files changed, 2847 insertions(+), 172 deletions(-) create mode 100644 .claude/settings.json create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/ActivitiesActivity.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/ActivityType.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/GpxExporter.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/GpxImporter.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/LocationHelper.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/MainActivity.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/MobileSyncService.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/TrackDetailActivity.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/TrackPoint.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/TrackStorage.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/TrackingService.kt create mode 100644 mobile/src/main/java/de/tstratmann/gpstracker/WindData.kt delete mode 100644 mobile/src/main/java/de/tstratmann/gpstracker_wear/MainActivity.kt delete mode 100644 mobile/src/main/java/de/tstratmann/gpstracker_wear/MobileSyncService.kt create mode 100644 mobile/src/main/res/drawable/ic_wind_arrow.xml create mode 100644 mobile/src/main/res/drawable/white_circle_background.xml create mode 100644 mobile/src/main/res/drawable/wind_section_background.xml create mode 100644 mobile/src/main/res/layout/activity_activities.xml create mode 100644 mobile/src/main/res/layout/activity_track_detail.xml create mode 100644 mobile/src/main/res/menu/menu_activities.xml create mode 100644 mobile/src/main/res/xml/file_paths.xml rename wear/src/main/java/de/tstratmann/{gpstracker_wear => gpstracker}/GPXManager.kt (88%) rename wear/src/main/java/de/tstratmann/{gpstracker_wear => gpstracker}/TrackingService.kt (95%) rename wear/src/main/java/de/tstratmann/{gpstracker_wear => gpstracker}/WearSyncManager.kt (92%) rename wear/src/main/java/de/tstratmann/{gpstracker_wear => gpstracker}/presentation/MainActivity.kt (95%) create mode 100644 wear/src/main/java/de/tstratmann/gpstracker/presentation/theme/Theme.kt delete mode 100644 wear/src/main/java/de/tstratmann/gpstracker_wear/presentation/theme/Theme.kt 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 { + 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 + } +} diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/TrackingService.kt b/mobile/src/main/java/de/tstratmann/gpstracker/TrackingService.kt new file mode 100644 index 0000000..6256e00 --- /dev/null +++ b/mobile/src/main/java/de/tstratmann/gpstracker/TrackingService.kt @@ -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() + 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 = 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() + } +} diff --git a/mobile/src/main/java/de/tstratmann/gpstracker/WindData.kt b/mobile/src/main/java/de/tstratmann/gpstracker/WindData.kt new file mode 100644 index 0000000..302e0e4 --- /dev/null +++ b/mobile/src/main/java/de/tstratmann/gpstracker/WindData.kt @@ -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¤t=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 + } + } +} diff --git a/mobile/src/main/java/de/tstratmann/gpstracker_wear/MainActivity.kt b/mobile/src/main/java/de/tstratmann/gpstracker_wear/MainActivity.kt deleted file mode 100644 index ea91afe..0000000 --- a/mobile/src/main/java/de/tstratmann/gpstracker_wear/MainActivity.kt +++ /dev/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 - } - } -} \ No newline at end of file diff --git a/mobile/src/main/java/de/tstratmann/gpstracker_wear/MobileSyncService.kt b/mobile/src/main/java/de/tstratmann/gpstracker_wear/MobileSyncService.kt deleted file mode 100644 index fb2e877..0000000 --- a/mobile/src/main/java/de/tstratmann/gpstracker_wear/MobileSyncService.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/mobile/src/main/res/drawable/ic_wind_arrow.xml b/mobile/src/main/res/drawable/ic_wind_arrow.xml new file mode 100644 index 0000000..830c901 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_wind_arrow.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/white_circle_background.xml b/mobile/src/main/res/drawable/white_circle_background.xml new file mode 100644 index 0000000..739a264 --- /dev/null +++ b/mobile/src/main/res/drawable/white_circle_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/mobile/src/main/res/drawable/wind_section_background.xml b/mobile/src/main/res/drawable/wind_section_background.xml new file mode 100644 index 0000000..b1775b2 --- /dev/null +++ b/mobile/src/main/res/drawable/wind_section_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/mobile/src/main/res/layout/activity_activities.xml b/mobile/src/main/res/layout/activity_activities.xml new file mode 100644 index 0000000..33c38ea --- /dev/null +++ b/mobile/src/main/res/layout/activity_activities.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/activity_main.xml b/mobile/src/main/res/layout/activity_main.xml index 86a5d97..3f69aca 100644 --- a/mobile/src/main/res/layout/activity_main.xml +++ b/mobile/src/main/res/layout/activity_main.xml @@ -1,19 +1,370 @@ - - + + + + + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@+id/map" + app:layout_constraintVertical_weight="40"> - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/activity_track_detail.xml b/mobile/src/main/res/layout/activity_track_detail.xml new file mode 100644 index 0000000..f9454fb --- /dev/null +++ b/mobile/src/main/res/layout/activity_track_detail.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/menu/menu_activities.xml b/mobile/src/main/res/menu/menu_activities.xml new file mode 100644 index 0000000..663acd8 --- /dev/null +++ b/mobile/src/main/res/menu/menu_activities.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/mobile/src/main/res/values-night/themes.xml b/mobile/src/main/res/values-night/themes.xml index d1ee21b..2ba7ce7 100644 --- a/mobile/src/main/res/values-night/themes.xml +++ b/mobile/src/main/res/values-night/themes.xml @@ -1,7 +1,8 @@ - - - \ No newline at end of file + diff --git a/mobile/src/main/res/values/colors.xml b/mobile/src/main/res/values/colors.xml index c8524cd..e06638a 100644 --- a/mobile/src/main/res/values/colors.xml +++ b/mobile/src/main/res/values/colors.xml @@ -2,4 +2,8 @@ #FF000000 #FFFFFFFF - \ No newline at end of file + #4CAF50 + #388E3C + #2196F3 + #4CAF50 + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index fc72877..afc8cf2 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -1,3 +1,10 @@ + - GPSTracker_Wear - \ No newline at end of file + GPS Tracker + Start Aufzeichnung + Stop Aufzeichnung + GPX Export + Bereit + Aufzeichnung läuft + Gestoppt + diff --git a/mobile/src/main/res/values/themes.xml b/mobile/src/main/res/values/themes.xml index af753e8..b80e201 100644 --- a/mobile/src/main/res/values/themes.xml +++ b/mobile/src/main/res/values/themes.xml @@ -1,9 +1,8 @@ - - - - -