wear und applaufen, allerdings wear ohne kmh
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read(//c/Users/thors/AppData/Local/Android/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3
-3
@@ -4,16 +4,16 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="mobile">
|
<SelectionState runConfigName="mobile">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-03-03T11:02:54.897757100Z">
|
<DropdownSelection timestamp="2026-03-03T15:09:55.401831700Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=28161JEEJR069K" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\thors\.android\avd\Wear_OS_Large_Round.avd" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
<SelectionState runConfigName="wear">
|
<SelectionState runConfigName="gpstracker">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
|
|||||||
Generated
-1
@@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ espressoCore = "3.7.0"
|
|||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
playServicesWearable = "19.0.0"
|
playServicesWearable = "19.0.0"
|
||||||
playServicesLocation = "21.3.0"
|
playServicesLocation = "21.3.0"
|
||||||
|
playServicesMaps = "19.1.0"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
activity = "1.12.4"
|
activity = "1.12.4"
|
||||||
constraintlayout = "2.2.1"
|
constraintlayout = "2.2.1"
|
||||||
@@ -18,6 +19,8 @@ wearToolingPreview = "1.0.0"
|
|||||||
activityCompose = "1.12.4"
|
activityCompose = "1.12.4"
|
||||||
coreSplashscreen = "1.2.0"
|
coreSplashscreen = "1.2.0"
|
||||||
kotlinxCoroutines = "1.9.0"
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
gson = "2.10.1"
|
||||||
|
documentfile = "1.0.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -27,6 +30,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
|
|||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" }
|
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" }
|
||||||
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
|
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
|
||||||
|
play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||||
@@ -46,7 +50,11 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
|
|||||||
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
|
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" }
|
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||||
|
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
+27
-9
@@ -1,23 +1,29 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val localProperties = Properties()
|
||||||
|
val localPropertiesFile = rootProject.file("local.properties")
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.reader().use { localProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.tstratmann.gpstracker_wear"
|
namespace = "de.tstratmann.gpstracker"
|
||||||
compileSdk {
|
compileSdk = 36
|
||||||
version = release(36) {
|
|
||||||
minorApiLevel = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.tstratmann.gpstracker_wear"
|
applicationId = "de.tstratmann.gpstracker"
|
||||||
minSdk = 30
|
minSdk = 30
|
||||||
targetSdk = 36
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
manifestPlaceholders["MAPS_API_KEY"] = localProperties.getProperty("MAPS_API_KEY", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -29,21 +35,33 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.play.services.wearable)
|
implementation(libs.play.services.wearable)
|
||||||
|
implementation(libs.play.services.location)
|
||||||
|
implementation(libs.play.services.maps)
|
||||||
implementation(libs.kotlinx.coroutines.play.services)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.androidx.activity)
|
implementation(libs.androidx.activity)
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -10,7 +22,39 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
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
|
<service
|
||||||
android:name=".MobileSyncService"
|
android:name=".MobileSyncService"
|
||||||
@@ -22,14 +66,15 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<activity
|
<provider
|
||||||
android:name=".MainActivity"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:exported="true">
|
android:authorities="${applicationId}.fileprovider"
|
||||||
<intent-filter>
|
android:exported="false"
|
||||||
<action android:name="android.intent.action.MAIN" />
|
android:grantUriPermissions="true">
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<meta-data
|
||||||
</intent-filter>
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
</activity>
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</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"?>
|
<?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:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/main"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".MainActivity">
|
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_width="wrap_content"
|
||||||
android:layout_height="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_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="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">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme. -->
|
<!-- Dark theme override - inherits from Theme.GPSTracker -->
|
||||||
<style name="Base.Theme.GPSTracker_Wear" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.GPSTracker" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
<!-- Customize your dark theme here. -->
|
<item name="colorPrimary">@color/primary</item>
|
||||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||||
|
<item name="colorAccent">@color/accent</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,4 +2,8 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</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>
|
<resources>
|
||||||
<string name="app_name">GPSTracker_Wear</string>
|
<string name="app_name">GPS Tracker</string>
|
||||||
</resources>
|
<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">
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Base application theme. -->
|
<resources>
|
||||||
<style name="Base.Theme.GPSTracker_Wear" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.GPSTracker" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
<!-- Customize your light theme here. -->
|
<item name="colorPrimary">@color/primary</item>
|
||||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||||
|
<item name="colorAccent">@color/accent</item>
|
||||||
</style>
|
</style>
|
||||||
|
</resources>
|
||||||
<style name="Theme.GPSTracker_Wear" parent="Base.Theme.GPSTracker_Wear" />
|
|
||||||
</resources>
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="gpx_files" path="/" />
|
||||||
|
</paths>
|
||||||
@@ -4,11 +4,11 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.tstratmann.gpstracker_wear"
|
namespace = "de.tstratmann.gpstracker"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.tstratmann.gpstracker_wear"
|
applicationId = "de.tstratmann.gpstracker"
|
||||||
minSdk = 30
|
minSdk = 30
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
@@ -31,6 +31,7 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -49,4 +50,4 @@ dependencies {
|
|||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -1,4 +1,4 @@
|
|||||||
package de.tstratmann.gpstracker_wear
|
package de.tstratmann.gpstracker
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
@@ -24,16 +24,14 @@ class GPXManager(private val context: Context) {
|
|||||||
val time = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
val time = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
}.format(Date(location.time))
|
}.format(Date(location.time))
|
||||||
|
|
||||||
val trkpt = """
|
val trkpt = """
|
||||||
<trkpt lat="${location.latitude}" lon="${location.longitude}">
|
<trkpt lat="${location.latitude}" lon="${location.longitude}">
|
||||||
<ele>${location.altitude}</ele>
|
<ele>${location.altitude}</ele>
|
||||||
<time>$time</time>
|
<time>$time</time>
|
||||||
</trkpt>
|
</trkpt>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
// This is a simple append, for a real app we'd want to handle the footer properly
|
|
||||||
// But for this exercise, we'll strip the footer, append, then re-add footer
|
|
||||||
val content = file.readText().removeSuffix(getGpxFooter())
|
val content = file.readText().removeSuffix(getGpxFooter())
|
||||||
file.writeText(content + trkpt + getGpxFooter())
|
file.writeText(content + trkpt + getGpxFooter())
|
||||||
}
|
}
|
||||||
+9
-10
@@ -1,4 +1,4 @@
|
|||||||
package de.tstratmann.gpstracker_wear
|
package de.tstratmann.gpstracker
|
||||||
|
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -11,7 +11,6 @@ import com.google.android.gms.location.*
|
|||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class TrackingService : Service() {
|
class TrackingService : Service() {
|
||||||
|
|
||||||
@@ -41,9 +40,9 @@ class TrackingService : Service() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
||||||
gpxManager = GPXManager(this)
|
gpxManager = GPXManager(this)
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
locationCallback = object : LocationCallback() {
|
locationCallback = object : LocationCallback() {
|
||||||
override fun onLocationResult(locationResult: LocationResult) {
|
override fun onLocationResult(locationResult: LocationResult) {
|
||||||
locationResult.lastLocation?.let { location ->
|
locationResult.lastLocation?.let { location ->
|
||||||
@@ -55,17 +54,17 @@ class TrackingService : Service() {
|
|||||||
|
|
||||||
private fun processLocation(location: Location) {
|
private fun processLocation(location: Location) {
|
||||||
gpxManager.appendLocation(location)
|
gpxManager.appendLocation(location)
|
||||||
|
|
||||||
val currentData = _trackingState.value
|
val currentData = _trackingState.value
|
||||||
var newDistance = currentData.distanceKm
|
var newDistance = currentData.distanceKm
|
||||||
|
|
||||||
lastLocation?.let { last ->
|
lastLocation?.let { last ->
|
||||||
newDistance += last.distanceTo(location) / 1000.0
|
newDistance += last.distanceTo(location) / 1000.0
|
||||||
}
|
}
|
||||||
|
|
||||||
lastLocation = location
|
lastLocation = location
|
||||||
val speedKmh = location.speed * 3.6 // m/s to km/h
|
val speedKmh = location.speed * 3.6
|
||||||
|
|
||||||
_trackingState.value = currentData.copy(
|
_trackingState.value = currentData.copy(
|
||||||
distanceKm = newDistance,
|
distanceKm = newDistance,
|
||||||
speedKmh = speedKmh
|
speedKmh = speedKmh
|
||||||
@@ -88,7 +87,7 @@ class TrackingService : Service() {
|
|||||||
startTime = System.currentTimeMillis()
|
startTime = System.currentTimeMillis()
|
||||||
lastLocation = null
|
lastLocation = null
|
||||||
_trackingState.value = TrackingData()
|
_trackingState.value = TrackingData()
|
||||||
|
|
||||||
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
|
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
|
||||||
.setMinUpdateIntervalMillis(1000)
|
.setMinUpdateIntervalMillis(1000)
|
||||||
.build()
|
.build()
|
||||||
+4
-5
@@ -1,7 +1,6 @@
|
|||||||
package de.tstratmann.gpstracker_wear
|
package de.tstratmann.gpstracker
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.android.gms.wearable.Asset
|
import com.google.android.gms.wearable.Asset
|
||||||
import com.google.android.gms.wearable.PutDataMapRequest
|
import com.google.android.gms.wearable.PutDataMapRequest
|
||||||
@@ -15,12 +14,12 @@ class WearSyncManager(private val context: Context) {
|
|||||||
return try {
|
return try {
|
||||||
val bytes = file.readBytes()
|
val bytes = file.readBytes()
|
||||||
val asset = Asset.createFromBytes(bytes)
|
val asset = Asset.createFromBytes(bytes)
|
||||||
|
|
||||||
val request = PutDataMapRequest.create("/gpx_export/${file.name}").apply {
|
val request = PutDataMapRequest.create("/gpx_export/${file.name}").apply {
|
||||||
dataMap.putAsset("gpx_file", asset)
|
dataMap.putAsset("gpx_file", asset)
|
||||||
dataMap.putLong("timestamp", System.currentTimeMillis())
|
dataMap.putLong("timestamp", System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
|
|
||||||
val putDataRequest = request.asPutDataRequest().setUrgent()
|
val putDataRequest = request.asPutDataRequest().setUrgent()
|
||||||
Wearable.getDataClient(context).putDataItem(putDataRequest).await()
|
Wearable.getDataClient(context).putDataItem(putDataRequest).await()
|
||||||
Log.d("WearSyncManager", "File synced: ${file.name}")
|
Log.d("WearSyncManager", "File synced: ${file.name}")
|
||||||
@@ -30,4 +29,4 @@ class WearSyncManager(private val context: Context) {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-12
@@ -1,4 +1,4 @@
|
|||||||
package de.tstratmann.gpstracker_wear.presentation
|
package de.tstratmann.gpstracker.presentation
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
@@ -28,12 +28,11 @@ import androidx.wear.compose.material.*
|
|||||||
import androidx.wear.compose.navigation.SwipeDismissableNavHost
|
import androidx.wear.compose.navigation.SwipeDismissableNavHost
|
||||||
import androidx.wear.compose.navigation.composable
|
import androidx.wear.compose.navigation.composable
|
||||||
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
|
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
|
||||||
import de.tstratmann.gpstracker_wear.GPXManager
|
import de.tstratmann.gpstracker.GPXManager
|
||||||
import de.tstratmann.gpstracker_wear.TrackingService
|
import de.tstratmann.gpstracker.TrackingService
|
||||||
import de.tstratmann.gpstracker_wear.WearSyncManager
|
import de.tstratmann.gpstracker.WearSyncManager
|
||||||
import de.tstratmann.gpstracker_wear.presentation.theme.GPSTracker_WearTheme
|
import de.tstratmann.gpstracker.presentation.theme.GPSTrackerTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -47,7 +46,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun WearApp() {
|
fun WearApp() {
|
||||||
val navController = rememberSwipeDismissableNavController()
|
val navController = rememberSwipeDismissableNavController()
|
||||||
GPSTracker_WearTheme {
|
GPSTrackerTheme {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
timeText = { TimeText() }
|
timeText = { TimeText() }
|
||||||
) {
|
) {
|
||||||
@@ -165,9 +164,9 @@ fun TrackingScreen(navController: NavHostController) {
|
|||||||
Text(text = timeString, style = MaterialTheme.typography.display1)
|
Text(text = timeString, style = MaterialTheme.typography.display1)
|
||||||
Text(text = String.format("%.2f km", trackingData.distanceKm), style = MaterialTheme.typography.body1)
|
Text(text = String.format("%.2f km", trackingData.distanceKm), style = MaterialTheme.typography.body1)
|
||||||
Text(text = String.format("%.1f km/h", trackingData.speedKmh), style = MaterialTheme.typography.body1)
|
Text(text = String.format("%.1f km/h", trackingData.speedKmh), style = MaterialTheme.typography.body1)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
trackingService?.stopTracking()
|
trackingService?.stopTracking()
|
||||||
@@ -184,7 +183,7 @@ fun TrackingScreen(navController: NavHostController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
if (isBound) {
|
if (isBound) {
|
||||||
@@ -210,7 +209,7 @@ fun ActivitiesScreen(navController: NavHostController) {
|
|||||||
Text(text = "$count Aktivitäten", style = MaterialTheme.typography.title3)
|
Text(text = "$count Aktivitäten", style = MaterialTheme.typography.title3)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val files = gpxManager.getAllFiles()
|
val files = gpxManager.getAllFiles()
|
||||||
if (files.isNotEmpty()) {
|
if (files.isNotEmpty()) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -240,4 +239,4 @@ fun ActivitiesScreen(navController: NavHostController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.tstratmann.gpstracker.presentation.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.wear.compose.material.MaterialTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GPSTrackerTheme(
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package de.tstratmann.gpstracker_wear.presentation.theme
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.wear.compose.material.MaterialTheme
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GPSTracker_WearTheme(
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Empty theme to customize for your app.
|
|
||||||
* See: https://developer.android.com/jetpack/compose/designsystems/custom
|
|
||||||
*/
|
|
||||||
MaterialTheme(
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user