wear und applaufen, allerdings wear ohne kmh
This commit is contained in:
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
}
|
||||
}
|
||||
@@ -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¤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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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¤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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="gpx_files" path="/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user