wear und applaufen, allerdings wear ohne kmh

This commit is contained in:
Thorsten
2026-03-06 08:20:54 +01:00
parent c2cfcda942
commit 3d2d68d145
40 changed files with 2847 additions and 172 deletions
+27 -9
View File
@@ -1,23 +1,29 @@
import java.util.Properties
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.reader().use { localProperties.load(it) }
}
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "de.tstratmann.gpstracker_wear"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
namespace = "de.tstratmann.gpstracker"
compileSdk = 36
defaultConfig {
applicationId = "de.tstratmann.gpstracker_wear"
applicationId = "de.tstratmann.gpstracker"
minSdk = 30
targetSdk = 36
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders["MAPS_API_KEY"] = localProperties.getProperty("MAPS_API_KEY", "")
}
buildTypes {
@@ -29,21 +35,33 @@ android {
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.play.services.wearable)
implementation(libs.play.services.location)
implementation(libs.play.services.maps)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.gson)
implementation(libs.androidx.documentfile)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
}
+55 -10
View File
@@ -2,6 +2,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -10,7 +22,39 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GPSTracker_Wear">
android:theme="@style/Theme.GPSTracker">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ActivitiesActivity"
android:exported="false"
android:screenOrientation="portrait"
android:parentActivityName=".MainActivity" />
<activity
android:name=".TrackDetailActivity"
android:exported="false"
android:screenOrientation="portrait"
android:parentActivityName=".ActivitiesActivity" />
<service
android:name=".TrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location" />
<service
android:name=".MobileSyncService"
@@ -22,14 +66,15 @@
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
</manifest>
@@ -0,0 +1,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
}
}
@@ -0,0 +1,19 @@
package de.tstratmann.gpstracker
enum class ActivityType(
val label: String,
val updateIntervalMs: Long,
val minDistanceM: Float,
val useKnots: Boolean
) {
WING_FOILING("WingFoilen", 1000L, 1f, true),
SUP("SUP", 1000L, 1f, true),
CYCLING("Radfahren", 1000L, 3f, false),
RUNNING("Laufen", 1000L, 3f, false),
HIKING("Wandern", 1000L, 3f, false),
OTHER("Sonstiges", 1000L, 1f, false);
fun formatSpeed(metersPerSecond: Float): String {
return "${(metersPerSecond * 3.6).toInt()} km/h"
}
}
@@ -0,0 +1,131 @@
package de.tstratmann.gpstracker
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.*
class GpxExporter(private val context: Context) {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
fun exportAndShare(track: Track) {
val gpxContent = generateGpxContent(track)
val file = saveGpxFile(track.name, gpxContent)
shareGpxFile(file)
}
fun generateGpxContent(track: Track): String {
val builder = StringBuilder()
builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
builder.append("<gpx version=\"1.1\" creator=\"GPS Tracker App\" ")
builder.append("xmlns=\"http://www.topografix.com/GPX/1/1\" ")
builder.append("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" ")
builder.append("xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 ")
builder.append("http://www.topografix.com/GPX/1/1/gpx.xsd\">\n")
builder.append(" <metadata>\n")
builder.append(" <name>${escapeXml(track.name)}</name>\n")
builder.append(" <time>${dateFormat.format(Date(track.createdAt))}</time>\n")
builder.append(" </metadata>\n")
builder.append(" <trk>\n")
builder.append(" <name>${escapeXml(track.name)}</name>\n")
builder.append(" <trkseg>\n")
for (point in track.points) {
builder.append(" <trkpt lat=\"${point.latitude}\" lon=\"${point.longitude}\">\n")
builder.append(" <ele>${point.altitude}</ele>\n")
builder.append(" <time>${dateFormat.format(Date(point.timestamp))}</time>\n")
if (point.speed > 0 || point.bearing > 0) {
builder.append(" <extensions>\n")
if (point.speed > 0) builder.append(" <speed>${point.speed}</speed>\n")
if (point.bearing > 0) builder.append(" <course>${point.bearing}</course>\n")
builder.append(" </extensions>\n")
}
builder.append(" </trkpt>\n")
}
builder.append(" </trkseg>\n")
builder.append(" </trk>\n")
builder.append("</gpx>")
return builder.toString()
}
private fun saveGpxFile(trackName: String, content: String): File {
val fileName = sanitizeFileName(trackName) + ".gpx"
val file = File(context.cacheDir, fileName)
FileWriter(file).use { writer ->
writer.write(content)
}
return file
}
fun saveGpxToPermanentStorage(track: Track): File? {
return try {
val baseDir = context.getExternalFilesDir(null)
?: context.filesDir
val exportDir = File(baseDir, "exports")
if (!exportDir.exists()) {
exportDir.mkdirs()
}
val fileName = sanitizeFileName(track.name) + ".gpx"
val file = File(exportDir, fileName)
val gpxContent = generateGpxContent(track)
FileWriter(file).use { writer ->
writer.write(gpxContent)
}
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9.-_]"), "_")
}
private fun shareGpxFile(file: File) {
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "application/gpx+xml"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(shareIntent, "GPX Track teilen")
)
}
private fun escapeXml(text: String): String {
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
}
}
@@ -0,0 +1,155 @@
package de.tstratmann.gpstracker
import android.content.Context
import android.net.Uri
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.*
class GpxImporter(private val context: Context) {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
fun importFromUri(uri: Uri): Track? {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
parseGpxFile(inputStream)
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun importFromString(content: String): Track? {
return try {
parseGpxFile(content.byteInputStream(Charsets.UTF_8))
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun parseGpxFile(inputStream: InputStream): Track? {
val points = mutableListOf<TrackPoint>()
var trackName = "Imported Track ${SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN).format(Date())}"
var totalDistance = 0.0
try {
val factory = XmlPullParserFactory.newInstance()
val parser = factory.newPullParser()
parser.setInput(inputStream, "UTF-8")
var eventType = parser.eventType
var currentLat: Double? = null
var currentLon: Double? = null
var currentEle: Double = 0.0
var currentTime: Long = 0L
var currentSpeed: Float = 0f
var currentBearing: Float = 0f
var inTrkpt = false
var inMetadata = false
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
when (parser.name) {
"metadata" -> inMetadata = true
"name" -> {
if (inMetadata && parser.next() == XmlPullParser.TEXT) {
trackName = parser.text
}
}
"trkpt" -> {
inTrkpt = true
currentLat = parser.getAttributeValue(null, "lat")?.toDoubleOrNull()
currentLon = parser.getAttributeValue(null, "lon")?.toDoubleOrNull()
}
"ele" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentEle = parser.text?.toDoubleOrNull() ?: 0.0
}
}
"time" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentTime = try {
dateFormat.parse(parser.text)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
}
"speed" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentSpeed = parser.text?.toFloatOrNull() ?: 0f
}
}
"course" -> {
if (inTrkpt && parser.next() == XmlPullParser.TEXT) {
currentBearing = parser.text?.toFloatOrNull() ?: 0f
}
}
}
}
XmlPullParser.END_TAG -> {
when (parser.name) {
"metadata" -> inMetadata = false
"trkpt" -> {
if (currentLat != null && currentLon != null) {
points.add(
TrackPoint(
latitude = currentLat,
longitude = currentLon,
altitude = currentEle,
speed = currentSpeed,
timestamp = currentTime,
accuracy = 0f,
bearing = currentBearing
)
)
}
inTrkpt = false
currentLat = null
currentLon = null
currentEle = 0.0
currentSpeed = 0f
currentBearing = 0f
currentTime = 0L
}
}
}
}
eventType = parser.next()
}
if (points.size > 1) {
for (i in 1 until points.size) {
totalDistance += LocationHelper.calculateDistance(
points[i - 1].latitude,
points[i - 1].longitude,
points[i].latitude,
points[i].longitude
)
}
}
return if (points.isNotEmpty()) {
Track(
name = trackName,
points = points,
totalDistance = totalDistance,
createdAt = points.firstOrNull()?.timestamp ?: System.currentTimeMillis()
)
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}
@@ -0,0 +1,51 @@
package de.tstratmann.gpstracker
import kotlin.math.*
object LocationHelper {
fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val earthRadius = 6371.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadius * c
}
fun calculateTotalDistance(points: List<TrackPoint>): Double {
if (points.size < 2) return 0.0
var totalDistance = 0.0
for (i in 1 until points.size) {
val previous = points[i - 1]
val current = points[i]
totalDistance += calculateDistance(
previous.latitude,
previous.longitude,
current.latitude,
current.longitude
)
}
return totalDistance
}
fun metersPerSecondToKmh(mps: Float): Int {
return (mps * 3.6).roundToInt()
}
fun metersPerSecondToKnots(mps: Float): Int {
return (mps * 1.94384).roundToInt()
}
fun formatCoordinates(latitude: Double, longitude: Double): String {
return "%.6f, %.6f".format(latitude, longitude)
}
}
@@ -0,0 +1,513 @@
package de.tstratmann.gpstracker
import android.Manifest
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.Color
import android.location.Location
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import de.tstratmann.gpstracker.databinding.ActivityMainBinding
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.*
import java.text.SimpleDateFormat
import java.util.*
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var binding: ActivityMainBinding
private lateinit var googleMap: GoogleMap
private lateinit var trackStorage: TrackStorage
private lateinit var fusedLocationClient: FusedLocationProviderClient
private var trackingService: TrackingService? = null
private var serviceBound = false
private var isTracking = false
private var selectedActivityType: ActivityType = ActivityType.WING_FOILING
private var currentMarker: Marker? = null
private var trackPolyline: Polyline? = null
private val trackLatLngs = mutableListOf<LatLng>()
private val dateFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN)
private var trackingStartTime: Long = 0
private val handler = Handler(Looper.getMainLooper())
private val durationUpdateRunnable = object : Runnable {
override fun run() {
if (isTracking) {
updateDuration()
handler.postDelayed(this, 1000)
}
}
}
private val windDataClient = WindDataClient()
private var lastWindUpdateTime: Long = 0
private val WIND_UPDATE_INTERVAL = 60000L
private var currentWindData: WindData? = null
private var currentWindLatitude: Double = 0.0
private var currentWindLongitude: Double = 0.0
// Receiver for newly synced wear tracks
private val syncReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
showToast("Neuer Wear-Track synchronisiert")
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
if (allGranted) {
enableMyLocation()
moveToCurrentLocation()
} else {
showToast("GPS-Berechtigung erforderlich!")
}
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as TrackingService.TrackingBinder
trackingService = binder.getService()
serviceBound = true
trackingService?.onLocationUpdate = { trackPoint, totalDistance ->
runOnUiThread {
updateMapWithLocation(trackPoint)
updateStats(trackPoint, totalDistance)
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceBound = false
trackingService = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
trackStorage = TrackStorage(this)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
val mapFragment = supportFragmentManager
.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
setupButtons()
bindTrackingService()
checkPermissions()
}
override fun onResume() {
super.onResume()
val filter = IntentFilter(MobileSyncService.ACTION_TRACK_SYNCED)
ContextCompat.registerReceiver(this, syncReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onPause() {
super.onPause()
unregisterReceiver(syncReceiver)
}
override fun onMapReady(map: GoogleMap) {
googleMap = map
googleMap.apply {
mapType = GoogleMap.MAP_TYPE_NORMAL
uiSettings.isZoomControlsEnabled = true
uiSettings.isCompassEnabled = true
uiSettings.isMyLocationButtonEnabled = true
}
googleMap.setOnCameraMoveListener {
updateWindArrowRotation()
}
if (hasLocationPermission()) {
enableMyLocation()
moveToCurrentLocation()
}
trackPolyline = googleMap.addPolyline(
PolylineOptions()
.color(Color.RED)
.width(8f)
.geodesic(true)
)
}
private fun moveToCurrentLocation() {
if (!hasLocationPermission()) return
try {
fusedLocationClient.lastLocation.addOnSuccessListener { location: Location? ->
location?.let {
val currentLatLng = LatLng(it.latitude, it.longitude)
googleMap.animateCamera(
CameraUpdateFactory.newLatLngZoom(currentLatLng, 17f)
)
fetchWindData(it.latitude, it.longitude)
}
}
} catch (e: SecurityException) {
e.printStackTrace()
}
}
private fun setupButtons() {
binding.startStopButton.setOnClickListener {
toggleTracking()
}
binding.activitiesButton.setOnClickListener {
openActivitiesScreen()
}
binding.mapTypeButton.setOnClickListener {
toggleMapType()
}
binding.windWebButton.setOnClickListener {
openWindWebsite()
}
}
private fun toggleMapType() {
googleMap.mapType = when (googleMap.mapType) {
GoogleMap.MAP_TYPE_NORMAL -> {
showToast("Satellit")
GoogleMap.MAP_TYPE_SATELLITE
}
GoogleMap.MAP_TYPE_SATELLITE -> {
showToast("Hybrid")
GoogleMap.MAP_TYPE_HYBRID
}
else -> {
showToast("Karte")
GoogleMap.MAP_TYPE_NORMAL
}
}
}
private fun toggleTracking() {
if (!hasLocationPermission()) {
checkPermissions()
return
}
if (isTracking) {
stopTracking()
} else {
showActivitySelectionDialog()
}
}
private fun showActivitySelectionDialog() {
val activities = ActivityType.entries.toTypedArray()
val labels = activities.map { it.label }.toTypedArray()
var selectedIndex = activities.indexOf(selectedActivityType)
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("Aktivität wählen")
.setSingleChoiceItems(labels, selectedIndex) { _, which ->
selectedIndex = which
}
.setPositiveButton("Start") { _, _ ->
selectedActivityType = activities[selectedIndex]
startTracking()
}
.setNegativeButton("Abbrechen", null)
.show()
}
private fun startTracking() {
trackingService?.clearTrack()
trackLatLngs.clear()
trackPolyline?.points = trackLatLngs
trackingStartTime = System.currentTimeMillis()
binding.durationText.text = "00:00:00"
binding.distanceText.text = "0.00 km"
binding.speedText.text = "0 km/h"
binding.headingText.text = "0° Grad"
trackingService?.startTracking(selectedActivityType)
isTracking = true
handler.post(durationUpdateRunnable)
binding.startStopButton.apply {
text = "Stop"
setBackgroundColor(ContextCompat.getColor(this@MainActivity, android.R.color.holo_red_dark))
}
binding.statusText.text = selectedActivityType.label
binding.statusCard.setCardBackgroundColor(Color.RED)
binding.activitiesButton.visibility = android.view.View.GONE
showToast("GPS Tracking gestartet")
}
private fun stopTracking() {
trackingService?.stopTracking()
isTracking = false
handler.removeCallbacks(durationUpdateRunnable)
saveCurrentTrack()
binding.startStopButton.apply {
text = "Start"
setBackgroundColor(ContextCompat.getColor(this@MainActivity, android.R.color.holo_green_dark))
}
binding.statusText.text = "Gestoppt"
binding.statusCard.setCardBackgroundColor(Color.WHITE)
binding.activitiesButton.visibility = android.view.View.VISIBLE
showToast("GPS Tracking gestoppt")
}
private fun updateDuration() {
val durationMillis = System.currentTimeMillis() - trackingStartTime
val seconds = (durationMillis / 1000) % 60
val minutes = (durationMillis / (1000 * 60)) % 60
val hours = (durationMillis / (1000 * 60 * 60))
binding.durationText.text = String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
private fun updateMapWithLocation(trackPoint: TrackPoint) {
val latLng = LatLng(trackPoint.latitude, trackPoint.longitude)
if (currentMarker == null) {
currentMarker = googleMap.addMarker(
MarkerOptions()
.position(latLng)
.title("Aktuelle Position")
.icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE))
)
} else {
currentMarker?.position = latLng
}
trackLatLngs.add(latLng)
trackPolyline?.points = trackLatLngs
googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, 17f))
updateWindDataIfNeeded(trackPoint.latitude, trackPoint.longitude)
}
private fun updateStats(trackPoint: TrackPoint, totalDistance: Double) {
binding.distanceText.text = "%.2f km".format(totalDistance)
binding.speedText.text = selectedActivityType.formatSpeed(trackPoint.speed)
val bearing = getBearingInDegrees(trackPoint)
binding.headingText.text = "${bearing.toInt()}° Grad"
}
private fun getBearingInDegrees(trackPoint: TrackPoint): Float {
val points = trackingService?.getTrackPoints() ?: emptyList()
if (points.size < 2) return 0f
val previousPoint = points[points.size - 2]
return calculateBearing(
previousPoint.latitude,
previousPoint.longitude,
trackPoint.latitude,
trackPoint.longitude
)
}
private fun calculateBearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
val lat1Rad = Math.toRadians(lat1)
val lat2Rad = Math.toRadians(lat2)
val lonDiff = Math.toRadians(lon2 - lon1)
val y = Math.sin(lonDiff) * Math.cos(lat2Rad)
val x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(lonDiff)
var bearing = Math.toDegrees(Math.atan2(y, x))
bearing = (bearing + 360) % 360
return bearing.toFloat()
}
private fun saveCurrentTrack() {
val points = trackingService?.getTrackPoints() ?: emptyList()
if (points.isEmpty()) return
val activity = trackingService?.getActivityType() ?: selectedActivityType
val trackName = "${activity.label} ${dateFormat.format(Date())}"
val totalDistance = trackingService?.getTotalDistance() ?: 0.0
val track = Track(
name = trackName,
activityType = activity,
points = points,
totalDistance = totalDistance
)
trackStorage.saveTrack(track)
showToast("Track gespeichert: $trackName")
}
private fun openActivitiesScreen() {
val intent = Intent(this, ActivitiesActivity::class.java)
startActivity(intent)
}
private fun openWindWebsite() {
if (currentWindData?.isAvailable == true) {
val url = "https://open-meteo.com/en/docs#latitude=$currentWindLatitude&longitude=$currentWindLongitude&current=wind_speed_10m,wind_direction_10m"
val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))
startActivity(intent)
} else {
showToast("Keine Wind-Daten verfügbar")
}
}
private fun bindTrackingService() {
val intent = Intent(this, TrackingService::class.java)
startService(intent)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun enableMyLocation() {
if (hasLocationPermission()) {
try {
googleMap.isMyLocationEnabled = true
} catch (e: SecurityException) {
e.printStackTrace()
}
}
}
private fun hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
private fun checkPermissions() {
val permissions = mutableListOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
permissionLauncher.launch(permissions.toTypedArray())
}
private fun updateWindDataIfNeeded(latitude: Double, longitude: Double) {
val currentTime = System.currentTimeMillis()
if (currentTime - lastWindUpdateTime >= WIND_UPDATE_INTERVAL) {
lastWindUpdateTime = currentTime
fetchWindData(latitude, longitude)
}
}
private fun fetchWindData(latitude: Double, longitude: Double) {
currentWindLatitude = latitude
currentWindLongitude = longitude
lifecycleScope.launch {
try {
val windData = windDataClient.fetchWindData(latitude, longitude)
withContext(Dispatchers.Main) {
if (windData != null) {
currentWindData = windData
updateWindDisplay(windData)
} else {
currentWindData = WindData.unavailable()
updateWindDisplay(currentWindData!!)
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
currentWindData = WindData.unavailable()
updateWindDisplay(currentWindData!!)
}
}
}
}
private fun updateWindDisplay(windData: WindData) {
binding.windSpeedText.text = windData.getFormattedSpeed()
binding.windDirectionText.text = windData.getFormattedDirection()
if (windData.isAvailable) {
binding.windLocationText.text = "Lokation: %.4f, %.4f".format(currentWindLatitude, currentWindLongitude)
} else {
binding.windLocationText.text = "Lokation: --"
}
if (windData.isAvailable) {
binding.windArrow.visibility = android.view.View.VISIBLE
updateWindArrowRotation()
} else {
binding.windArrow.visibility = android.view.View.GONE
}
}
private fun updateWindArrowRotation() {
val windData = currentWindData
if (windData?.isAvailable == true) {
val mapBearing = googleMap.cameraPosition.bearing
val adjustedRotation = windData.direction - mapBearing
binding.windArrow.rotation = adjustedRotation
}
}
private fun showToast(message: String) {
val toast = Toast.makeText(this, message, Toast.LENGTH_SHORT)
toast.setGravity(Gravity.CENTER, 0, 0)
toast.show()
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacks(durationUpdateRunnable)
if (serviceBound) {
unbindService(serviceConnection)
serviceBound = false
}
}
}
@@ -0,0 +1,74 @@
package de.tstratmann.gpstracker
import android.content.Intent
import android.util.Log
import com.google.android.gms.wearable.*
import kotlinx.coroutines.*
import kotlinx.coroutines.tasks.await
class MobileSyncService : WearableListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
companion object {
const val ACTION_TRACK_SYNCED = "de.tstratmann.gpstracker.TRACK_SYNCED"
private const val TAG = "MobileSyncService"
}
override fun onDataChanged(dataEvents: DataEventBuffer) {
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
val path = event.dataItem.uri.path
if (path?.startsWith("/gpx_export/") == true) {
val dataMapItem = DataMapItem.fromDataItem(event.dataItem)
val asset = dataMapItem.dataMap.getAsset("gpx_file")
val fileName = path.substringAfterLast("/")
if (asset != null) {
scope.launch {
importGpxFromAsset(asset, fileName)
}
}
}
}
}
}
private suspend fun importGpxFromAsset(asset: Asset, fileName: String) {
try {
val fd = Wearable.getDataClient(this).getFdForAsset(asset).await()
val gpxContent = fd.inputStream.bufferedReader().use { it.readText() }
Log.d(TAG, "Received GPX from wear: $fileName")
val importer = GpxImporter(this)
val track = importer.importFromString(gpxContent)
if (track != null) {
val storage = TrackStorage(this)
val trackName = fileName.removeSuffix(".gpx").replace("_", " ")
val namedTrack = track.copy(name = trackName)
if (storage.getTrack(namedTrack.name) == null) {
storage.saveTrack(namedTrack)
Log.d(TAG, "Track saved: ${namedTrack.name}")
} else {
Log.d(TAG, "Track already exists: ${namedTrack.name}")
}
withContext(Dispatchers.Main) {
sendBroadcast(Intent(ACTION_TRACK_SYNCED))
}
} else {
Log.e(TAG, "Failed to parse GPX: $fileName")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to import asset: $fileName", e)
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}
@@ -0,0 +1,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
}
}
@@ -0,0 +1,89 @@
package de.tstratmann.gpstracker
import android.location.Location
import java.util.Date
data class TrackPoint(
val latitude: Double,
val longitude: Double,
val altitude: Double,
val speed: Float,
val timestamp: Long,
val accuracy: Float,
val bearing: Float = 0f
) {
companion object {
fun fromLocation(location: Location): TrackPoint {
return TrackPoint(
latitude = location.latitude,
longitude = location.longitude,
altitude = location.altitude,
speed = location.speed,
timestamp = location.time,
accuracy = location.accuracy,
bearing = location.bearing
)
}
}
fun getDate(): Date {
return Date(timestamp)
}
}
data class Track(
val id: Long = System.currentTimeMillis(),
val name: String,
val activityType: ActivityType? = null,
val points: List<TrackPoint>,
val totalDistance: Double,
val createdAt: Long = System.currentTimeMillis()
) {
val timestamp: Long get() = createdAt
fun getActivityTypeOrDefault(): ActivityType = activityType ?: ActivityType.WING_FOILING
fun getFormattedDistance(): String {
return "%.2f km".format(totalDistance)
}
fun getPointsCount(): Int {
return points.size
}
fun getDurationMillis(): Long {
if (points.size < 2) return 0
return points.last().timestamp - points.first().timestamp
}
fun getFormattedDuration(): String {
val durationMillis = getDurationMillis()
val seconds = (durationMillis / 1000) % 60
val minutes = (durationMillis / (1000 * 60)) % 60
val hours = (durationMillis / (1000 * 60 * 60))
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
fun getAverageSpeed(): Float {
if (points.isEmpty()) return 0f
val speeds = points.map { it.speed }.filter { it > 0 }
return if (speeds.isEmpty()) 0f else speeds.average().toFloat()
}
fun getAverageSpeedKmh(): Int {
return LocationHelper.metersPerSecondToKmh(getAverageSpeed())
}
fun getMaxSpeed(): Float {
if (points.isEmpty()) return 0f
return points.maxOfOrNull { it.speed } ?: 0f
}
fun getMaxSpeedKmh(): Int {
return LocationHelper.metersPerSecondToKmh(getMaxSpeed())
}
fun getFormattedAverageSpeed(): String = getActivityTypeOrDefault().formatSpeed(getAverageSpeed())
fun getFormattedMaxSpeed(): String = getActivityTypeOrDefault().formatSpeed(getMaxSpeed())
}
@@ -0,0 +1,130 @@
package de.tstratmann.gpstracker
import android.content.Context
import com.google.gson.Gson
import java.io.File
class TrackStorage(private val context: Context) {
private val gson = Gson()
companion object {
private const val TRACKS_DIR = "tracks"
private const val FILE_EXTENSION = ".json"
}
private fun getTracksDirectory(): File {
val baseDir = context.getExternalFilesDir(null)
?: context.filesDir
val tracksDir = File(baseDir, TRACKS_DIR)
if (!tracksDir.exists()) {
tracksDir.mkdirs()
}
return tracksDir
}
fun saveTrack(track: Track) {
try {
val fileName = sanitizeFileName(track.name) + FILE_EXTENSION
val file = File(getTracksDirectory(), fileName)
val json = gson.toJson(track)
file.writeText(json)
android.util.Log.d("TrackStorage", "Track saved to: ${file.absolutePath}")
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error saving track", e)
}
}
fun getAllTracks(): List<Track> {
return try {
val tracksDir = getTracksDirectory()
val trackFiles = tracksDir.listFiles { file ->
file.extension == "json"
} ?: emptyArray()
trackFiles.mapNotNull { file ->
try {
val json = file.readText()
gson.fromJson(json, Track::class.java)
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error reading track: ${file.name}", e)
null
}
}.sortedByDescending { it.timestamp }
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error getting all tracks", e)
emptyList()
}
}
fun deleteTrack(trackName: String) {
try {
val fileName = sanitizeFileName(trackName) + FILE_EXTENSION
val file = File(getTracksDirectory(), fileName)
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
android.util.Log.e("TrackStorage", "Error deleting track", e)
}
}
fun deleteTrackById(trackId: Long) {
try {
val tracks = getAllTracks()
val track = tracks.find { it.id == trackId }
track?.let { deleteTrack(it.name) }
} catch (e: Exception) {
e.printStackTrace()
}
}
fun clearAllTracks() {
try {
val tracksDir = getTracksDirectory()
tracksDir.listFiles()?.forEach { it.delete() }
} catch (e: Exception) {
e.printStackTrace()
}
}
fun getTrackCount(): Int {
return try {
val tracksDir = getTracksDirectory()
tracksDir.listFiles { file -> file.extension == "json" }?.size ?: 0
} catch (e: Exception) {
0
}
}
fun getTrack(trackName: String): Track? {
return try {
val fileName = sanitizeFileName(trackName) + FILE_EXTENSION
val file = File(getTracksDirectory(), fileName)
if (file.exists()) {
val json = file.readText()
gson.fromJson(json, Track::class.java)
} else {
null
}
} catch (e: Exception) {
null
}
}
fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9.-_]"), "_")
}
fun getStoragePath(): String {
return getTracksDirectory().absolutePath
}
}
@@ -0,0 +1,177 @@
package de.tstratmann.gpstracker
import android.app.*
import android.content.Context
import android.content.Intent
import android.location.Location
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.*
class TrackingService : Service() {
private val binder = TrackingBinder()
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var locationCallback: LocationCallback
private val trackPoints = mutableListOf<TrackPoint>()
private var totalDistance = 0.0
private var lastLocation: Location? = null
private var isTracking = false
private var currentActivityType: ActivityType = ActivityType.WING_FOILING
var onLocationUpdate: ((TrackPoint, Double) -> Unit)? = null
companion object {
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "gps_tracking_channel"
}
fun getActivityType(): ActivityType = currentActivityType
inner class TrackingBinder : Binder() {
fun getService(): TrackingService = this@TrackingService
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onCreate() {
super.onCreate()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
createNotificationChannel()
setupLocationCallback()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, createNotification())
return START_STICKY
}
fun startTracking(activityType: ActivityType = ActivityType.WING_FOILING) {
if (isTracking) return
currentActivityType = activityType
isTracking = true
trackPoints.clear()
totalDistance = 0.0
lastLocation = null
try {
val locationRequest = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY,
activityType.updateIntervalMs
).apply {
setMinUpdateIntervalMillis(activityType.updateIntervalMs)
setMaxUpdateDelayMillis(activityType.updateIntervalMs * 2)
setMinUpdateDistanceMeters(activityType.minDistanceM)
}.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper()
)
updateNotification("${activityType.label} aktiv...")
} catch (e: SecurityException) {
e.printStackTrace()
}
}
fun stopTracking() {
isTracking = false
fusedLocationClient.removeLocationUpdates(locationCallback)
updateNotification("Tracking gestoppt")
}
fun getTrackPoints(): List<TrackPoint> = trackPoints.toList()
fun getTotalDistance(): Double = totalDistance
fun clearTrack() {
trackPoints.clear()
totalDistance = 0.0
lastLocation = null
}
private fun setupLocationCallback() {
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
handleLocationUpdate(location)
}
}
}
}
private fun handleLocationUpdate(location: Location) {
val trackPoint = TrackPoint.fromLocation(location)
trackPoints.add(trackPoint)
lastLocation?.let { last ->
val distance = LocationHelper.calculateDistance(
last.latitude, last.longitude,
location.latitude, location.longitude
)
totalDistance += distance
}
lastLocation = location
val speedFormatted = currentActivityType.formatSpeed(location.speed)
updateNotification(
"Distanz: ${"%.2f".format(totalDistance)} km | " +
"Geschw.: $speedFormatted"
)
onLocationUpdate?.invoke(trackPoint, totalDistance)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"GPS Tracking",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Zeigt GPS Tracking Status an"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun createNotification(text: String = "GPS Tracker läuft"): Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("GPS Tracker")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val notification = createNotification(text)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, notification)
}
override fun onDestroy() {
super.onDestroy()
stopTracking()
}
}
@@ -0,0 +1,99 @@
package de.tstratmann.gpstracker
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
data class WindData(
val speed: Float,
val direction: Int,
val isAvailable: Boolean = true
) {
companion object {
fun unavailable(): WindData {
return WindData(0f, 0, false)
}
}
fun getFormattedSpeed(): String {
return if (isAvailable) "${speed.toInt()} km/h" else "N/A"
}
fun getFormattedDirection(): String {
return if (isAvailable) "$direction°" else "N/A"
}
fun getCompassDirection(): String {
if (!isAvailable) return "N/A"
return when {
direction < 22.5 || direction >= 337.5 -> "N"
direction < 67.5 -> "NO"
direction < 112.5 -> "O"
direction < 157.5 -> "SO"
direction < 202.5 -> "S"
direction < 247.5 -> "SW"
direction < 292.5 -> "W"
direction < 337.5 -> "NW"
else -> "N/A"
}
}
}
class WindDataClient {
companion object {
private const val TAG = "WindDataClient"
private const val API_URL = "https://api.open-meteo.com/v1/forecast"
}
suspend fun fetchWindData(latitude: Double, longitude: Double): WindData? {
return withContext(Dispatchers.IO) {
try {
val urlString = "$API_URL?latitude=$latitude&longitude=$longitude&current=wind_speed_10m,wind_direction_10m"
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.apply {
requestMethod = "GET"
connectTimeout = 5000
readTimeout = 5000
}
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().use { it.readText() }
parseWindData(response)
} else {
Log.e(TAG, "API returned error code: $responseCode")
null
}
} catch (e: Exception) {
Log.e(TAG, "Error fetching wind data", e)
null
}
}
}
private fun parseWindData(jsonResponse: String): WindData? {
return try {
val json = JSONObject(jsonResponse)
val current = json.getJSONObject("current")
val windSpeed = current.getDouble("wind_speed_10m").toFloat()
val windDirection = current.getInt("wind_direction_10m")
WindData(
speed = windSpeed,
direction = windDirection,
isAvailable = true
)
} catch (e: Exception) {
Log.e(TAG, "Error parsing wind data", e)
null
}
}
}
@@ -1,20 +0,0 @@
package de.tstratmann.gpstracker_wear
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}
@@ -1,54 +0,0 @@
package de.tstratmann.gpstracker_wear
import android.util.Log
import com.google.android.gms.wearable.*
import kotlinx.coroutines.*
import kotlinx.coroutines.tasks.await
import java.io.File
import java.io.FileOutputStream
class MobileSyncService : WearableListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onDataChanged(dataEvents: DataEventBuffer) {
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
val path = event.dataItem.uri.path
if (path?.startsWith("/gpx_export/") == true) {
val dataMapItem = DataMapItem.fromDataItem(event.dataItem)
val asset = dataMapItem.dataMap.getAsset("gpx_file")
val fileName = path.substringAfterLast("/")
if (asset != null) {
scope.launch {
saveAssetToFile(asset, fileName)
}
}
}
}
}
}
private suspend fun saveAssetToFile(asset: Asset, fileName: String) {
try {
val fd = Wearable.getDataClient(this).getFdForAsset(asset).await()
val inputStream = fd.inputStream
val file = File(getExternalFilesDir(null), fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
Log.d("MobileSyncService", "File saved: ${file.absolutePath}")
// Hier könnte man einen Broadcast senden, um die UI zu benachrichtigen
} catch (e: Exception) {
Log.e("MobileSyncService", "Failed to save asset", e)
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Arrow shaft -->
<path
android:fillColor="#FF5722"
android:pathData="M11,3 L13,3 L13,18 L11,18 Z" />
<!-- Arrow head (triangle pointing up) -->
<path
android:fillColor="#FF5722"
android:pathData="M12,0 L18,8 L6,8 Z" />
<!-- Arrow tail (small wings) -->
<path
android:fillColor="#FF5722"
android:pathData="M8,20 L12,18 L16,20 L12,22 Z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFF" />
<size
android:width="56dp"
android:height="56dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E8F4FD" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F5F5">
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Gespeicherte Tracks"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="16dp" />
<LinearLayout
android:id="@+id/tracksContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:id="@+id/storageInfoText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0 Track(s) gespeichert"
android:textSize="11sp"
android:textColor="@android:color/darker_gray"
android:layout_marginTop="16dp"
android:padding="8dp" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
+357 -6
View File
@@ -1,19 +1,370 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
<!-- Map Fragment - 60% of screen -->
<fragment
android:id="@+id/map"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/bottomContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_weight="60" />
<!-- Status Badge -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/statusCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:layout_margin="16dp"
app:cardCornerRadius="20dp"
app:cardElevation="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
android:text="Bereit"
android:textColor="@android:color/black"
android:textSize="14sp"
android:textStyle="bold" />
</com.google.android.material.card.MaterialCardView>
<!-- Wind Arrow Indicator -->
<ImageView
android:id="@+id/windArrow"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_margin="16dp"
android:padding="12dp"
android:src="@drawable/ic_wind_arrow"
android:background="@drawable/white_circle_background"
android:elevation="6dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/statusCard" />
<!-- Map Type Toggle Button -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/mapTypeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_mapmode"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/bottomContainer"
app:backgroundTint="#FFFFFF"
app:tint="@android:color/black"
app:fabSize="normal" />
<!-- Bottom Container - 40% of screen -->
<LinearLayout
android:id="@+id/bottomContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@+id/map"
app:layout_constraintVertical_weight="40">
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Stats Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/statsCard"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_margin="0dp"
app:cardCornerRadius="0dp"
app:cardElevation="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp"
android:gravity="center">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Row 1: Duration and Distance -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DAUER"
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/durationText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00:00"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DISTANZ"
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/distanceText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.00 km"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- Row 2: Speed and Heading -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="GESCHW."
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/speedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0 km/h"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RICHTUNG"
android:textColor="@android:color/darker_gray"
android:textSize="9sp" />
<TextView
android:id="@+id/headingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0° Grad"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="6dp"
android:background="#DDEAF4" />
<!-- Wind Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/wind_section_background"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="6dp"
android:paddingBottom="6dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2"
android:layout_marginBottom="2dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WIND"
android:textColor="#5B87A8"
android:textSize="9sp" />
<TextView
android:id="@+id/windSpeedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="N/A"
android:textColor="#1A5276"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WIND RICHTUNG"
android:textColor="#5B87A8"
android:textSize="9sp" />
<TextView
android:id="@+id/windDirectionText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="N/A"
android:textColor="#1A5276"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingTop="2dp">
<TextView
android:id="@+id/windLocationText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lokation: --"
android:textColor="#5B87A8"
android:textSize="10sp" />
<ImageButton
android:id="@+id/windWebButton"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_info_details"
android:scaleType="fitCenter"
android:contentDescription="Wind-Daten Webseite öffnen" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Controls Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/controlsCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
app:cardCornerRadius="0dp"
app:cardElevation="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/startStopButton"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="1"
android:layout_marginEnd="6dp"
android:backgroundTint="#4CAF50"
android:text="Start"
android:textSize="14sp"
app:cornerRadius="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/activitiesButton"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:backgroundTint="#9C27B0"
android:text="Aktivitäten"
android:textSize="14sp"
android:visibility="visible"
app:cornerRadius="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/mapDetail"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/infoCard"
app:layout_constraintHeight_percent="0.5" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/mapTypeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_mapmode"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@+id/infoCard"
app:backgroundTint="#FFFFFF"
app:tint="@android:color/black"
app:fabSize="normal" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/infoCard"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="0dp"
app:cardCornerRadius="0dp"
app:cardElevation="8dp"
app:layout_constraintTop_toBottomOf="@+id/mapDetail"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/trackNameText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Track Name"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="24dp" />
<LinearLayout
android:id="@+id/statsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_drive_backup"
android:title="Google Drive Backup"
android:icon="@android:drawable/ic_menu_save"
app:showAsAction="never" />
<item
android:id="@+id/action_drive_restore"
android:title="Google Drive Restore"
android:icon="@android:drawable/ic_menu_upload"
app:showAsAction="never" />
<item
android:id="@+id/action_import_gpx"
android:title="GPX importieren"
android:icon="@android:drawable/ic_menu_add"
app:showAsAction="never" />
</menu>
+6 -5
View File
@@ -1,7 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.GPSTracker_Wear" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
<!-- Dark theme override - inherits from Theme.GPSTracker -->
<style name="Theme.GPSTracker" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
</style>
</resources>
</resources>
+5 -1
View File
@@ -2,4 +2,8 @@
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
<color name="primary">#4CAF50</color>
<color name="primary_dark">#388E3C</color>
<color name="accent">#2196F3</color>
<color name="ic_launcher_background">#4CAF50</color>
</resources>
+9 -2
View File
@@ -1,3 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">GPSTracker_Wear</string>
</resources>
<string name="app_name">GPS Tracker</string>
<string name="start_tracking">Start Aufzeichnung</string>
<string name="stop_tracking">Stop Aufzeichnung</string>
<string name="export_gpx">GPX Export</string>
<string name="status_ready">Bereit</string>
<string name="status_recording">Aufzeichnung läuft</string>
<string name="status_stopped">Gestoppt</string>
</resources>
+7 -8
View File
@@ -1,9 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.GPSTracker_Wear" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GPSTracker" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
</style>
<style name="Theme.GPSTracker_Wear" parent="Base.Theme.GPSTracker_Wear" />
</resources>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="gpx_files" path="/" />
</paths>