Initial release of GPS2Audio
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
app/build/
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship made available under
|
||||||
|
the License, as indicated by a copyright notice that is included in
|
||||||
|
or attached to the work (an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and derivative works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean, as submitted to the Licensor for inclusion
|
||||||
|
in the Work by the copyright owner or by an individual or Legal Entity
|
||||||
|
authorized to submit on behalf of the copyright owner. For the purposes
|
||||||
|
of this definition, "submitted" means any form of electronic, verbal,
|
||||||
|
or written communication sent to the Licensor or its representatives,
|
||||||
|
including but not limited to communication on electronic mailing lists,
|
||||||
|
source code control systems, and issue tracking systems that are managed
|
||||||
|
by, or on behalf of, the Licensor for the purpose of discussing and
|
||||||
|
improving the Work, but excluding communication that is conspicuously
|
||||||
|
marked or designated in writing by the copyright owner as "Not a
|
||||||
|
Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any Legal Entity on behalf of
|
||||||
|
whom a Contribution has been received by the Licensor and subsequently
|
||||||
|
incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by the combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a cross-claim
|
||||||
|
or counterclaim in a lawsuit) alleging that the Work or any
|
||||||
|
Contribution embodied within the Work constitutes direct or
|
||||||
|
contributory patent infringement, then any patent licenses granted to
|
||||||
|
You under this License for that Work shall terminate as of the date
|
||||||
|
such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or Derivative Works
|
||||||
|
a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, You must include a readable copy of the attribution
|
||||||
|
notices contained within such NOTICE file, in at least one of
|
||||||
|
the following places: within a NOTICE text file distributed as
|
||||||
|
part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and do
|
||||||
|
not modify the License. You may add Your own attribution notices
|
||||||
|
within Derivative Works that You distribute, alongside or as an
|
||||||
|
addendum to the NOTICE text from the Work, provided that such
|
||||||
|
additional attribution notices cannot be construed as modifying
|
||||||
|
the License.
|
||||||
|
|
||||||
|
You may add Your own license statement for Your modifications and
|
||||||
|
may provide additional grant of rights to use, copy, modify, merge,
|
||||||
|
publish, distribute, sublicense, and/or sell copies of the Work,
|
||||||
|
and to permit persons to whom the Work is furnished to do so,
|
||||||
|
subject to the following conditions.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or reproducing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or exemplary damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or all other
|
||||||
|
commercial damages or losses), even if such Contributor has been
|
||||||
|
advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format in question. An additional
|
||||||
|
intellectual property notice may also be required by applicable law.
|
||||||
|
|
||||||
|
Copyright 2026 Marcel Mayer
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.waypointaudio"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "de.waypointaudio"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons.extended)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
|
implementation(libs.play.services.location)
|
||||||
|
implementation(libs.osmdroid)
|
||||||
|
implementation(libs.media3.exoplayer)
|
||||||
|
implementation(libs.media3.common)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
-keep class de.waypointaudio.data.** { *; }
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Location Permissions -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<!-- Background location for waypoint detection when app is not in foreground -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Foreground Service -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Notifications (Android 13+) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Microphone for Live/PTT -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<!-- Foreground service with microphone access (Android 14+) -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
||||||
|
<!-- Audio (MediaPlayer needs no special permission for local files) -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<!-- Fallback for Android < 13 -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Internet for osmdroid tile downloads -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Keep CPU alive while playing audio in service -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- Vibrate for notification (optional) -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".WaypointApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@drawable/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.WaypointAudioGuide">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Foreground location service (Wegpunkt-Erkennung) -->
|
||||||
|
<service
|
||||||
|
android:name=".service.WaypointLocationService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="location" />
|
||||||
|
|
||||||
|
<!-- Foreground location service (GPS-Track-Aufzeichnung im Hintergrund) -->
|
||||||
|
<service
|
||||||
|
android:name=".service.TrackRecordingService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="location" />
|
||||||
|
|
||||||
|
<!-- Live/PTT Microphone Service -->
|
||||||
|
<service
|
||||||
|
android:name=".service.LivePttService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
|
|
||||||
|
<!-- File provider for sound file selection (Android 7+) -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package de.waypointaudio
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import de.waypointaudio.ui.MapScreen
|
||||||
|
import de.waypointaudio.ui.PermissionRequiredScreen
|
||||||
|
import de.waypointaudio.ui.WaypointListScreen
|
||||||
|
import de.waypointaudio.ui.theme.WaypointAudioTheme
|
||||||
|
import de.waypointaudio.viewmodel.WaypointViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauptaktivität der App.
|
||||||
|
*
|
||||||
|
* Navigation:
|
||||||
|
* "list" → WaypointListScreen (Startbildschirm)
|
||||||
|
* "map" → MapScreen (osmdroid Kartenansicht)
|
||||||
|
*
|
||||||
|
* Berechtigungslogik:
|
||||||
|
* - Nur ACCESS_FINE_LOCATION und ACCESS_COARSE_LOCATION sind zwingend erforderlich
|
||||||
|
* (für Standort-Button und Karte). Ohne diese wird der PermissionScreen gezeigt.
|
||||||
|
* - POST_NOTIFICATIONS und Audiozugriff werden separat angefragt, blockieren
|
||||||
|
* aber nicht die Hauptoberfläche.
|
||||||
|
*/
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val viewModel: WaypointViewModel by viewModels()
|
||||||
|
|
||||||
|
/** Nur Standortberechtigungen steuern, ob die App-UI angezeigt wird. */
|
||||||
|
private val locationPermissionsGranted = mutableStateOf(false)
|
||||||
|
|
||||||
|
// Launcher für Standortberechtigungen (zwingend erforderlich)
|
||||||
|
private val locationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { results ->
|
||||||
|
// Mindestens ACCESS_COARSE_LOCATION muss gewährt sein
|
||||||
|
val coarseGranted = results[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||||
|
val fineGranted = results[Manifest.permission.ACCESS_FINE_LOCATION] == true
|
||||||
|
locationPermissionsGranted.value = coarseGranted || fineGranted
|
||||||
|
// Nach Gewährung von Standort: optionale Berechtigungen separat anfragen
|
||||||
|
if (locationPermissionsGranted.value) {
|
||||||
|
requestOptionalPermissions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launcher für optionale Berechtigungen (Benachrichtigungen, Audio)
|
||||||
|
private val optionalPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { /* optional – kein Block der Hauptoberfläche */ }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Standortberechtigungen prüfen und ggf. anfordern
|
||||||
|
checkAndRequestLocationPermissions()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) {
|
||||||
|
val locationGranted by locationPermissionsGranted
|
||||||
|
|
||||||
|
if (locationGranted) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = "list"
|
||||||
|
) {
|
||||||
|
composable("list") {
|
||||||
|
WaypointListScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToMap = { navController.navigate("map") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("map") {
|
||||||
|
MapScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PermissionRequiredScreen(
|
||||||
|
onPermissionsGranted = { checkAndRequestLocationPermissions() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Beim Zurückkehren aus den Einstellungen erneut prüfen
|
||||||
|
locationPermissionsGranted.value = hasLocationPermission()
|
||||||
|
if (locationPermissionsGranted.value) {
|
||||||
|
requestOptionalPermissions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Berechtigungslogik
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun checkAndRequestLocationPermissions() {
|
||||||
|
if (hasLocationPermission()) {
|
||||||
|
locationPermissionsGranted.value = true
|
||||||
|
requestOptionalPermissions()
|
||||||
|
} else {
|
||||||
|
locationPermissionLauncher.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft ob mindestens grobe oder feine Standortberechtigung vorhanden ist. */
|
||||||
|
private fun hasLocationPermission(): Boolean {
|
||||||
|
val fine = ContextCompat.checkSelfPermission(
|
||||||
|
this, Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val coarse = ContextCompat.checkSelfPermission(
|
||||||
|
this, Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
return fine || coarse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionale Berechtigungen (Benachrichtigungen, Audiozugriff) anfragen,
|
||||||
|
* nachdem Standortberechtigung gewährt wurde.
|
||||||
|
* Diese blockieren NICHT die Haupt-UI.
|
||||||
|
*/
|
||||||
|
private fun requestOptionalPermissions() {
|
||||||
|
val toRequest = buildList {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this@MainActivity, Manifest.permission.READ_MEDIA_AUDIO
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
add(Manifest.permission.READ_MEDIA_AUDIO)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this@MainActivity, Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toRequest.isNotEmpty()) {
|
||||||
|
optionalPermissionLauncher.launch(toRequest.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.waypointaudio
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-Klasse – initialisiert globale Ressourcen.
|
||||||
|
*/
|
||||||
|
class WaypointApp : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
"waypoint_service_channel",
|
||||||
|
"Wegpunkt-Ortungsdienst",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Läuft im Hintergrund, um Wegpunkte zu erkennen"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package de.waypointaudio.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Globale Audio-Routing-Einstellungen für Live/PTT.
|
||||||
|
*
|
||||||
|
* Gerätekennungen (AudioDeviceInfo.id) sind Sitzungs-spezifisch und können sich nach
|
||||||
|
* einem Neustart oder nach dem Trennen/Verbinden von Bluetooth-Geräten ändern.
|
||||||
|
* Wenn ein gespeichertes Gerät nicht mehr verfügbar ist, wird automatisch der
|
||||||
|
* Systemstandard verwendet.
|
||||||
|
*
|
||||||
|
* @param selectedInputDeviceId ID des Eingabegeräts, null = Systemstandard
|
||||||
|
* @param selectedOutputDeviceId ID des Ausgabegeräts, null = Systemstandard
|
||||||
|
*/
|
||||||
|
data class AudioRoutingSettings(
|
||||||
|
val selectedInputDeviceId: Int? = null,
|
||||||
|
val selectedOutputDeviceId: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
private val Context.audioRoutingDataStore by preferencesDataStore(name = "audio_routing_settings")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistenz-Schicht für Audio-Routing-Einstellungen via DataStore.
|
||||||
|
*/
|
||||||
|
class AudioRoutingStore(private val context: Context) {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
|
||||||
|
val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id")
|
||||||
|
// Sentinel: -1 means "use system default" (null cannot be stored as Int)
|
||||||
|
const val NO_DEVICE = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings: Flow<AudioRoutingSettings> = context.audioRoutingDataStore.data
|
||||||
|
.map { prefs ->
|
||||||
|
AudioRoutingSettings(
|
||||||
|
selectedInputDeviceId = prefs[KEY_INPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE },
|
||||||
|
selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun save(settings: AudioRoutingSettings) {
|
||||||
|
context.audioRoutingDataStore.edit { prefs ->
|
||||||
|
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
|
||||||
|
prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearInputDevice() {
|
||||||
|
context.audioRoutingDataStore.edit { prefs ->
|
||||||
|
prefs[KEY_INPUT_DEVICE_ID] = NO_DEVICE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearOutputDevice() {
|
||||||
|
context.audioRoutingDataStore.edit { prefs ->
|
||||||
|
prefs[KEY_OUTPUT_DEVICE_ID] = NO_DEVICE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.waypointaudio.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Punkt eines aufgezeichneten GPS-Tracks.
|
||||||
|
*
|
||||||
|
* @param latitude Breitengrad in Dezimalgrad
|
||||||
|
* @param longitude Längengrad in Dezimalgrad
|
||||||
|
* @param timestamp Unix-Millisekunden zum Zeitpunkt der Aufzeichnung
|
||||||
|
*/
|
||||||
|
data class GpsTrackPoint(
|
||||||
|
val latitude: Double = 0.0,
|
||||||
|
val longitude: Double = 0.0,
|
||||||
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.waypointaudio.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quelle der Begleitmusik für eine Tour.
|
||||||
|
*/
|
||||||
|
enum class MusicSourceType {
|
||||||
|
LOCAL_PLAYLIST,
|
||||||
|
STREAM_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhalten der Begleitmusik, wenn ein Wegpunkt-Audio abgespielt wird.
|
||||||
|
*/
|
||||||
|
enum class WaypointMusicBehavior {
|
||||||
|
/** Begleitmusik pausieren, danach fortsetzen. */
|
||||||
|
PAUSE_RESUME,
|
||||||
|
|
||||||
|
/** Begleitmusik ausblenden (Fade-out), Wegpunkt abspielen, danach einblenden (Fade-in). */
|
||||||
|
FADE_OUT_IN,
|
||||||
|
|
||||||
|
/** Begleitmusik auf duckVolume leise stellen, Wegpunkt parallel abspielen, danach wiederherstellen. */
|
||||||
|
DUCK_UNDERLAY,
|
||||||
|
|
||||||
|
/** Begleitmusik unveränderter Lautstärke weiterspielen, Wegpunkt parallel abspielen. */
|
||||||
|
CONTINUE_UNDERLAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelnes Element in der lokalen Playlist.
|
||||||
|
*/
|
||||||
|
data class PlaylistItem(
|
||||||
|
val uriString: String,
|
||||||
|
val displayName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begleitmusik-Einstellungen für eine Tour.
|
||||||
|
*
|
||||||
|
* @param enabled Ob Begleitmusik für diese Tour aktiv ist.
|
||||||
|
* @param sourceType Quelle: lokale Playlist oder Stream-URL.
|
||||||
|
* @param localPlaylist Liste von Audiodateien (content:// URIs) aus dem lokalen Speicher.
|
||||||
|
* @param streamUrl Direkte HTTP/HTTPS-Stream-URL (z. B. Internetradio).
|
||||||
|
* @param behavior Verhalten wenn Wegpunkt-Audio abgespielt wird.
|
||||||
|
* @param fadeDurationMs Dauer des Fade-Effekts in Millisekunden.
|
||||||
|
* @param duckVolume Lautstärke (0.0–1.0) während des Duck-Modus.
|
||||||
|
* @param shuffle Playlist zufällig abspielen.
|
||||||
|
* @param autoStartAfterWaypoint Nach Wegpunkt-Audio Begleitmusik automatisch starten,
|
||||||
|
* auch wenn sie vorher nicht spielte.
|
||||||
|
*/
|
||||||
|
data class TourAudioSettings(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val sourceType: MusicSourceType = MusicSourceType.LOCAL_PLAYLIST,
|
||||||
|
val localPlaylist: List<PlaylistItem> = emptyList(),
|
||||||
|
val streamUrl: String? = null,
|
||||||
|
val behavior: WaypointMusicBehavior = WaypointMusicBehavior.PAUSE_RESUME,
|
||||||
|
val fadeDurationMs: Long = 1500L,
|
||||||
|
val duckVolume: Float = 0.25f,
|
||||||
|
val shuffle: Boolean = false,
|
||||||
|
val autoStartAfterWaypoint: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.waypointaudio.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
// Separater DataStore für Begleitmusik-Einstellungen
|
||||||
|
private val Context.musicDataStore: DataStore<Preferences> by preferencesDataStore(name = "tour_music")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistiert Begleitmusik-Einstellungen pro Tour (Key: Tourname).
|
||||||
|
* Serialisiert als JSON Map<String, TourAudioSettings>.
|
||||||
|
*/
|
||||||
|
class TourMusicStore(private val context: Context) {
|
||||||
|
|
||||||
|
private val gson: Gson = GsonBuilder().create()
|
||||||
|
private val mapType = object : TypeToken<Map<String, TourAudioSettingsJson>>() {}.type
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_MUSIC_SETTINGS = stringPreferencesKey("music_settings_json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flow der Begleitmusik-Einstellungen für alle Touren. */
|
||||||
|
val settingsFlow: Flow<Map<String, TourAudioSettings>> =
|
||||||
|
context.musicDataStore.data.map { prefs ->
|
||||||
|
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
|
||||||
|
runCatching {
|
||||||
|
val raw: Map<String, TourAudioSettingsJson>? = gson.fromJson(json, mapType)
|
||||||
|
raw?.mapValues { (_, v) -> v.toDomain() } ?: emptyMap()
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert die Einstellungen für eine spezifische Tour. */
|
||||||
|
fun settingsForTour(tourName: String): Flow<TourAudioSettings> =
|
||||||
|
settingsFlow.map { map -> map[tourName] ?: TourAudioSettings() }
|
||||||
|
|
||||||
|
/** Speichert oder aktualisiert die Einstellungen für eine Tour. */
|
||||||
|
suspend fun save(tourName: String, settings: TourAudioSettings) {
|
||||||
|
context.musicDataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
|
||||||
|
val map: MutableMap<String, TourAudioSettingsJson> = runCatching {
|
||||||
|
gson.fromJson<Map<String, TourAudioSettingsJson>>(json, mapType)?.toMutableMap()
|
||||||
|
}.getOrNull() ?: mutableMapOf()
|
||||||
|
map[tourName] = TourAudioSettingsJson.fromDomain(settings)
|
||||||
|
prefs[KEY_MUSIC_SETTINGS] = gson.toJson(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht die Einstellungen für eine Tour (z. B. beim Tour-Löschen). */
|
||||||
|
suspend fun delete(tourName: String) {
|
||||||
|
context.musicDataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
|
||||||
|
val map: MutableMap<String, TourAudioSettingsJson> = runCatching {
|
||||||
|
gson.fromJson<Map<String, TourAudioSettingsJson>>(json, mapType)?.toMutableMap()
|
||||||
|
}.getOrNull() ?: mutableMapOf()
|
||||||
|
map.remove(tourName)
|
||||||
|
prefs[KEY_MUSIC_SETTINGS] = gson.toJson(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON-Repräsentation (Gson-kompatibel, ohne Kotlin-Enums direkt serialisieren)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
internal data class PlaylistItemJson(
|
||||||
|
val uriString: String = "",
|
||||||
|
val displayName: String = ""
|
||||||
|
) {
|
||||||
|
fun toDomain() = PlaylistItem(uriString, displayName)
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(item: PlaylistItem) = PlaylistItemJson(item.uriString, item.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class TourAudioSettingsJson(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val sourceType: String = "LOCAL_PLAYLIST",
|
||||||
|
val localPlaylist: List<PlaylistItemJson> = emptyList(),
|
||||||
|
val streamUrl: String? = null,
|
||||||
|
val behavior: String = "PAUSE_RESUME",
|
||||||
|
val fadeDurationMs: Long = 1500L,
|
||||||
|
val duckVolume: Float = 0.25f,
|
||||||
|
val shuffle: Boolean = false,
|
||||||
|
val autoStartAfterWaypoint: Boolean = false
|
||||||
|
) {
|
||||||
|
fun toDomain() = TourAudioSettings(
|
||||||
|
enabled = enabled,
|
||||||
|
sourceType = runCatching { MusicSourceType.valueOf(sourceType) }
|
||||||
|
.getOrDefault(MusicSourceType.LOCAL_PLAYLIST),
|
||||||
|
localPlaylist = localPlaylist.map { it.toDomain() },
|
||||||
|
streamUrl = streamUrl,
|
||||||
|
behavior = runCatching { WaypointMusicBehavior.valueOf(behavior) }
|
||||||
|
.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME),
|
||||||
|
fadeDurationMs = fadeDurationMs,
|
||||||
|
duckVolume = duckVolume,
|
||||||
|
shuffle = shuffle,
|
||||||
|
autoStartAfterWaypoint = autoStartAfterWaypoint
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(s: TourAudioSettings) = TourAudioSettingsJson(
|
||||||
|
enabled = s.enabled,
|
||||||
|
sourceType = s.sourceType.name,
|
||||||
|
localPlaylist = s.localPlaylist.map { PlaylistItemJson.fromDomain(it) },
|
||||||
|
streamUrl = s.streamUrl,
|
||||||
|
behavior = s.behavior.name,
|
||||||
|
fadeDurationMs = s.fadeDurationMs,
|
||||||
|
duckVolume = s.duckVolume,
|
||||||
|
shuffle = s.shuffle,
|
||||||
|
autoStartAfterWaypoint = s.autoStartAfterWaypoint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.waypointaudio.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tour-weite Abspiel-Vorgaben, die bei neuen Wegpunkten als Standardwerte verwendet werden
|
||||||
|
* und per Massen-Aktion auf alle vorhandenen Wegpunkte einer Tour angewendet werden können.
|
||||||
|
*
|
||||||
|
* @param tourName Name der Tour
|
||||||
|
* @param playbackMode Standard-Abspiel-Modus für neue und alle vorhandenen Wegpunkte
|
||||||
|
* @param maxPlayCount Standard-Maximale Abspielanzahl (nur für LIMITED_COUNT)
|
||||||
|
*/
|
||||||
|
data class TourPlaybackDefaults(
|
||||||
|
val tourName: String = Waypoint.DEFAULT_TOUR_NAME,
|
||||||
|
val playbackMode: PlaybackMode = PlaybackMode.EVERY_ENTRY,
|
||||||
|
val maxPlayCount: Int = 3
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.waypointaudio.data
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abspiel-Modus für einen Wegpunkt.
|
||||||
|
*
|
||||||
|
* EVERY_ENTRY – Standardverhalten: Ton bei jedem erneuten Betreten.
|
||||||
|
* ONCE – Ton nur beim ersten Betreten (playCount == 0).
|
||||||
|
* LIMITED_COUNT – Ton maximal [maxPlayCount] Mal insgesamt.
|
||||||
|
*/
|
||||||
|
enum class PlaybackMode {
|
||||||
|
EVERY_ENTRY,
|
||||||
|
ONCE,
|
||||||
|
LIMITED_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenmodell für einen Wegpunkt.
|
||||||
|
*
|
||||||
|
* @param id Eindeutige ID (UUID)
|
||||||
|
* @param name Anzeigename des Wegpunkts
|
||||||
|
* @param latitude Breitengrad in Dezimalgrad
|
||||||
|
* @param longitude Längengrad in Dezimalgrad
|
||||||
|
* @param radiusMeters Erkennungsradius in Metern
|
||||||
|
* @param soundUri URI zur Audiodatei (content:// oder file://)
|
||||||
|
* @param soundName Anzeigename der Audiodatei
|
||||||
|
* @param isActive Ob der Wegpunkt aktiv überwacht wird
|
||||||
|
* @param tourName Name der Tour/Route (Standard: "Standard"). Rückwärtskompatibel:
|
||||||
|
* bestehende Wegpunkte ohne dieses Feld erhalten automatisch "Standard".
|
||||||
|
*
|
||||||
|
* --- Abspielregeln (optional, Standardwerte = bisheriges Verhalten) ---
|
||||||
|
*
|
||||||
|
* @param playbackMode Abspiel-Modus (Standard: EVERY_ENTRY = bisheriges Verhalten)
|
||||||
|
* @param maxPlayCount Maximale Abspielanzahl für LIMITED_COUNT; null = kein Limit
|
||||||
|
* @param playCount Bisherige Abspielanzahl (persistent, wird vom Service inkrementiert)
|
||||||
|
* @param scheduleEnabled Ob der Zeitplan aktiv ist
|
||||||
|
* @param scheduleStartMillis Frühestes Datum/Uhrzeit (Unix-Millisekunden), ab dem Abspielen erlaubt ist
|
||||||
|
* @param scheduleEndMillis Spätestes Datum/Uhrzeit (Unix-Millisekunden), bis zu dem Abspielen erlaubt ist
|
||||||
|
* @param allowedStartMinutes Tagesminute (0–1439), ab der das tägliche Zeitfenster beginnt
|
||||||
|
* @param allowedEndMinutes Tagesminute (0–1439), bis zu der das tägliche Zeitfenster endet
|
||||||
|
*/
|
||||||
|
data class Waypoint(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val name: String = "",
|
||||||
|
val latitude: Double = 0.0,
|
||||||
|
val longitude: Double = 0.0,
|
||||||
|
val radiusMeters: Float = 50f,
|
||||||
|
val soundUri: String = "",
|
||||||
|
val soundName: String = "",
|
||||||
|
val isActive: Boolean = true,
|
||||||
|
|
||||||
|
// Tour-Zuordnung – safe default "Standard" für Rückwärtskompatibilität
|
||||||
|
val tourName: String = DEFAULT_TOUR_NAME,
|
||||||
|
|
||||||
|
// Abspielregeln – alle optional, Standardwerte entsprechen bisherigem Verhalten
|
||||||
|
val playbackMode: PlaybackMode = PlaybackMode.EVERY_ENTRY,
|
||||||
|
val maxPlayCount: Int? = null,
|
||||||
|
val playCount: Int = 0,
|
||||||
|
val scheduleEnabled: Boolean = false,
|
||||||
|
val scheduleStartMillis: Long? = null,
|
||||||
|
val scheduleEndMillis: Long? = null,
|
||||||
|
val allowedStartMinutes: Int? = null,
|
||||||
|
val allowedEndMinutes: Int? = null
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */
|
||||||
|
const val DEFAULT_TOUR_NAME = "Standard"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package de.waypointaudio.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
// DataStore-Instanz als Extension Property (Singleton pro Context)
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "waypoints")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokaler persistenter Speicher für Wegpunkte und Touren.
|
||||||
|
* Nutzt Jetpack DataStore mit JSON-Serialisierung via Gson.
|
||||||
|
*
|
||||||
|
* tourList speichert die geordnete Liste aller Tournamen (inkl. Standardtour).
|
||||||
|
* Touren werden zusätzlich aus den Wegpunkt-tourName-Feldern abgeleitet,
|
||||||
|
* damit auch leere Touren (ohne Wegpunkte) persistiert werden.
|
||||||
|
*
|
||||||
|
* tourPlaybackDefaults speichert die tour-weiten Abspiel-Vorgaben als JSON-Map.
|
||||||
|
*/
|
||||||
|
class WaypointStore(private val context: Context) {
|
||||||
|
|
||||||
|
private val gson: Gson = GsonBuilder().create()
|
||||||
|
private val waypointListType = object : TypeToken<List<Waypoint>>() {}.type
|
||||||
|
private val stringListType = object : TypeToken<List<String>>() {}.type
|
||||||
|
private val defaultsMapType = object : TypeToken<Map<String, TourPlaybackDefaults>>() {}.type
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_WAYPOINTS = stringPreferencesKey("waypoints_json")
|
||||||
|
private val KEY_TOURS = stringPreferencesKey("tours_json")
|
||||||
|
private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json")
|
||||||
|
private val KEY_GPS_TRACKS = stringPreferencesKey("gps_tracks_json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert einen Flow mit der aktuellen Wegpunktliste. */
|
||||||
|
val waypointsFlow: Flow<List<Waypoint>> = context.dataStore.data.map { prefs ->
|
||||||
|
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
||||||
|
runCatching {
|
||||||
|
gson.fromJson<List<Waypoint>>(json, waypointListType) ?: emptyList()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert einen Flow mit der persistierten Tourliste.
|
||||||
|
* Die Liste enthält mindestens die Standardtour.
|
||||||
|
* Touren aus Wegpunkten werden ggf. ergänzt, aber die Reihenfolge
|
||||||
|
* (und leere Touren) stammen aus diesem Key.
|
||||||
|
*/
|
||||||
|
val toursFlow: Flow<List<String>> = context.dataStore.data.map { prefs ->
|
||||||
|
val json = prefs[KEY_TOURS] ?: "[]"
|
||||||
|
val stored = runCatching {
|
||||||
|
gson.fromJson<List<String>>(json, stringListType) ?: emptyList()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
// Immer mindestens die Standardtour enthalten
|
||||||
|
if (stored.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else stored
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert einen Flow mit der Map tour → TourPlaybackDefaults. */
|
||||||
|
val tourDefaultsFlow: Flow<Map<String, TourPlaybackDefaults>> =
|
||||||
|
context.dataStore.data.map { prefs ->
|
||||||
|
val json = prefs[KEY_TOUR_DEFAULTS] ?: "{}"
|
||||||
|
runCatching {
|
||||||
|
gson.fromJson<Map<String, TourPlaybackDefaults>>(json, defaultsMapType) ?: emptyMap()
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPS-Track-Flow: Map tour → Liste von TrackPoint. */
|
||||||
|
val gpsTracksFlow: Flow<Map<String, List<GpsTrackPoint>>> =
|
||||||
|
context.dataStore.data.map { prefs ->
|
||||||
|
val json = prefs[KEY_GPS_TRACKS] ?: "{}"
|
||||||
|
runCatching {
|
||||||
|
val mapType = object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
|
||||||
|
gson.fromJson<Map<String, List<GpsTrackPoint>>>(json, mapType) ?: emptyMap()
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert die gesamte Wegpunktliste. */
|
||||||
|
suspend fun saveAll(waypoints: List<Waypoint>) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_WAYPOINTS] = gson.toJson(waypoints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert die gesamte Tourliste (Reihenfolge bleibt erhalten). */
|
||||||
|
suspend fun saveTours(tours: List<String>) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_TOURS] = gson.toJson(tours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert oder aktualisiert die Abspiel-Vorgaben einer Tour. */
|
||||||
|
suspend fun saveTourDefaults(tourName: String, defaults: TourPlaybackDefaults) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_TOUR_DEFAULTS] ?: "{}"
|
||||||
|
val map: MutableMap<String, TourPlaybackDefaults> = runCatching {
|
||||||
|
gson.fromJson<Map<String, TourPlaybackDefaults>>(json, defaultsMapType)
|
||||||
|
?.toMutableMap()
|
||||||
|
}.getOrNull() ?: mutableMapOf()
|
||||||
|
map[tourName] = defaults
|
||||||
|
prefs[KEY_TOUR_DEFAULTS] = gson.toJson(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Abspiel-Vorgaben einer Tour einmalig (ohne Flow). */
|
||||||
|
suspend fun getTourDefaults(tourName: String): TourPlaybackDefaults {
|
||||||
|
val json = context.dataStore.data.first()[KEY_TOUR_DEFAULTS] ?: "{}"
|
||||||
|
val map: Map<String, TourPlaybackDefaults> = runCatching {
|
||||||
|
gson.fromJson<Map<String, TourPlaybackDefaults>>(json, defaultsMapType) ?: emptyMap()
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
return map[tourName] ?: TourPlaybackDefaults(tourName = tourName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert den GPS-Track einer Tour. */
|
||||||
|
suspend fun saveGpsTrack(tourName: String, points: List<GpsTrackPoint>) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_GPS_TRACKS] ?: "{}"
|
||||||
|
val mapType = object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
|
||||||
|
val map: MutableMap<String, List<GpsTrackPoint>> = runCatching {
|
||||||
|
gson.fromJson<Map<String, List<GpsTrackPoint>>>(json, mapType)?.toMutableMap()
|
||||||
|
}.getOrNull() ?: mutableMapOf()
|
||||||
|
map[tourName] = points
|
||||||
|
prefs[KEY_GPS_TRACKS] = gson.toJson(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht den GPS-Track einer Tour. */
|
||||||
|
suspend fun clearGpsTrack(tourName: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_GPS_TRACKS] ?: "{}"
|
||||||
|
val mapType = object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
|
||||||
|
val map: MutableMap<String, List<GpsTrackPoint>> = runCatching {
|
||||||
|
gson.fromJson<Map<String, List<GpsTrackPoint>>>(json, mapType)?.toMutableMap()
|
||||||
|
}.getOrNull() ?: mutableMapOf()
|
||||||
|
map.remove(tourName)
|
||||||
|
prefs[KEY_GPS_TRACKS] = gson.toJson(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fügt einen Wegpunkt hinzu oder aktualisiert einen vorhandenen (matching id). */
|
||||||
|
suspend fun upsert(waypoint: Waypoint) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
||||||
|
val list: MutableList<Waypoint> = runCatching {
|
||||||
|
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
|
||||||
|
}.getOrNull() ?: mutableListOf()
|
||||||
|
|
||||||
|
val index = list.indexOfFirst { it.id == waypoint.id }
|
||||||
|
if (index >= 0) list[index] = waypoint else list.add(waypoint)
|
||||||
|
prefs[KEY_WAYPOINTS] = gson.toJson(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht einen Wegpunkt anhand seiner ID. */
|
||||||
|
suspend fun delete(id: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
||||||
|
val list: MutableList<Waypoint> = runCatching {
|
||||||
|
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
|
||||||
|
}.getOrNull() ?: mutableListOf()
|
||||||
|
list.removeAll { it.id == id }
|
||||||
|
prefs[KEY_WAYPOINTS] = gson.toJson(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inkrementiert den playCount eines Wegpunkts atomar.
|
||||||
|
* Wird vom WaypointLocationService nach erfolgreicher Wiedergabe aufgerufen.
|
||||||
|
* @return den neuen Wegpunkt mit inkrementiertem playCount, oder null wenn nicht gefunden.
|
||||||
|
*/
|
||||||
|
suspend fun incrementPlayCount(waypointId: String): Waypoint? {
|
||||||
|
var updated: Waypoint? = null
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
||||||
|
val list: MutableList<Waypoint> = runCatching {
|
||||||
|
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
|
||||||
|
}.getOrNull() ?: mutableListOf()
|
||||||
|
|
||||||
|
val index = list.indexOfFirst { it.id == waypointId }
|
||||||
|
if (index >= 0) {
|
||||||
|
val wp = list[index].copy(playCount = list[index].playCount + 1)
|
||||||
|
list[index] = wp
|
||||||
|
updated = wp
|
||||||
|
}
|
||||||
|
prefs[KEY_WAYPOINTS] = gson.toJson(list)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den playCount aller Wegpunkte einer Tour auf 0 zurück.
|
||||||
|
* @return Anzahl der zurückgesetzten Wegpunkte.
|
||||||
|
*/
|
||||||
|
suspend fun resetPlayCountsForTour(tourName: String): Int {
|
||||||
|
var count = 0
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
||||||
|
val list: MutableList<Waypoint> = runCatching {
|
||||||
|
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
|
||||||
|
}.getOrNull() ?: mutableListOf()
|
||||||
|
|
||||||
|
for (i in list.indices) {
|
||||||
|
val wp = list[i]
|
||||||
|
if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tourName && wp.playCount != 0) {
|
||||||
|
list[i] = wp.copy(playCount = 0)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs[KEY_WAYPOINTS] = gson.toJson(list)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet einen Abspiel-Modus auf alle Wegpunkte einer Tour an.
|
||||||
|
* Optional: setzt auch maxPlayCount, optional: setzt playCount zurück.
|
||||||
|
* @return Anzahl der aktualisierten Wegpunkte.
|
||||||
|
*/
|
||||||
|
suspend fun applyPlaybackModeForTour(
|
||||||
|
tourName: String,
|
||||||
|
mode: PlaybackMode,
|
||||||
|
maxPlayCount: Int?,
|
||||||
|
resetCounts: Boolean
|
||||||
|
): Int {
|
||||||
|
var count = 0
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
||||||
|
val list: MutableList<Waypoint> = runCatching {
|
||||||
|
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
|
||||||
|
}.getOrNull() ?: mutableListOf()
|
||||||
|
|
||||||
|
for (i in list.indices) {
|
||||||
|
val wp = list[i]
|
||||||
|
if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tourName) {
|
||||||
|
list[i] = wp.copy(
|
||||||
|
playbackMode = mode,
|
||||||
|
maxPlayCount = if (mode == PlaybackMode.LIMITED_COUNT) maxPlayCount else null,
|
||||||
|
playCount = if (resetCounts) 0 else wp.playCount
|
||||||
|
)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs[KEY_WAYPOINTS] = gson.toJson(list)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package de.waypointaudio.io
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import de.waypointaudio.data.PlaybackMode
|
||||||
|
import de.waypointaudio.data.Waypoint
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import org.xmlpull.v1.XmlPullParserFactory
|
||||||
|
import org.xmlpull.v1.XmlSerializer
|
||||||
|
import java.io.StringWriter
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet den Import und Export von Wegpunkten als JSON und GPX 1.1.
|
||||||
|
*
|
||||||
|
* JSON-Format: Array von Waypoint-Objekten (Gson-serialisiert, alle Felder inkl. tourName).
|
||||||
|
* Rückwärtskompatibel: Ältere JSON-Dateien ohne tourName erhalten automatisch
|
||||||
|
* den Standardwert [Waypoint.DEFAULT_TOUR_NAME] via Kotlin-Standardparameter.
|
||||||
|
*
|
||||||
|
* GPX-Format: GPX 1.1 mit <wpt>-Elementen.
|
||||||
|
* - lat, lon als Attribute
|
||||||
|
* - <name> aus waypoint.name
|
||||||
|
* - <desc> enthält Radius und Sound-Name (wenn vorhanden)
|
||||||
|
* - <extensions>: <wpa:radius>, <wpa:soundName>, <wpa:tourName> und alle Abspielregel-Felder
|
||||||
|
*/
|
||||||
|
object ImportExportManager {
|
||||||
|
|
||||||
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
private val waypointListType = object : TypeToken<List<Waypoint>>() {}.type
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JSON Export
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialisiert die Wegpunktliste als JSON-String.
|
||||||
|
* tourName wird automatisch durch Gson serialisiert (Kotlin data class).
|
||||||
|
*/
|
||||||
|
fun toJson(waypoints: List<Waypoint>): String = gson.toJson(waypoints)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt JSON in einen URI (via ContentResolver).
|
||||||
|
* @return Fehlermeldung oder null bei Erfolg.
|
||||||
|
*/
|
||||||
|
fun exportJson(context: Context, uri: Uri, waypoints: List<Waypoint>): String? {
|
||||||
|
return runCatching {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
|
out.writer().use { it.write(toJson(waypoints)) }
|
||||||
|
} ?: return "Konnte Datei nicht öffnen: $uri"
|
||||||
|
}.exceptionOrNull()?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JSON Import
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest Wegpunkte aus einem JSON-URI.
|
||||||
|
* Fehlende neue Felder (inkl. tourName) erhalten durch Kotlin-Standardwerte ihre sicheren Defaults.
|
||||||
|
* @return Pair(liste, fehler) – bei Fehler ist die Liste leer.
|
||||||
|
*/
|
||||||
|
fun importJson(context: Context, uri: Uri): Pair<List<Waypoint>, String?> {
|
||||||
|
return runCatching<Pair<List<Waypoint>, String?>> {
|
||||||
|
val json = context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() }
|
||||||
|
?: return Pair(emptyList(), "Datei konnte nicht gelesen werden")
|
||||||
|
val list: List<Waypoint>? = gson.fromJson(json, waypointListType)
|
||||||
|
if (list == null || list.isEmpty()) {
|
||||||
|
return Pair(emptyList(), "JSON enthält keine Wegpunkte")
|
||||||
|
}
|
||||||
|
// Sicherstellen, dass alle IDs eindeutig und tourName nicht leer sind
|
||||||
|
val sanitized = list.map { wp ->
|
||||||
|
wp.copy(
|
||||||
|
id = if (wp.id.isBlank()) UUID.randomUUID().toString() else wp.id,
|
||||||
|
tourName = wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Pair(sanitized, null)
|
||||||
|
}.getOrElse { e ->
|
||||||
|
Pair(emptyList(), "JSON-Fehler: ${e.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GPX Export
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen GPX 1.1 XML-String für die gegebene Wegpunktliste.
|
||||||
|
* Enthält alle Abspielregel-Felder und tourName als <wpa:...> Erweiterungen.
|
||||||
|
*/
|
||||||
|
fun toGpx(waypoints: List<Waypoint>): String {
|
||||||
|
val sw = StringWriter()
|
||||||
|
val factory = XmlPullParserFactory.newInstance()
|
||||||
|
factory.isNamespaceAware = true
|
||||||
|
val xs: XmlSerializer = factory.newSerializer()
|
||||||
|
xs.setOutput(sw)
|
||||||
|
xs.startDocument("UTF-8", true)
|
||||||
|
xs.text("\n")
|
||||||
|
|
||||||
|
xs.startTag("", "gpx")
|
||||||
|
xs.attribute("", "version", "1.1")
|
||||||
|
xs.attribute("", "creator", "GPS2Audio")
|
||||||
|
xs.attribute("", "xmlns", "http://www.topografix.com/GPX/1/1")
|
||||||
|
xs.attribute("", "xmlns:wpa", "https://github.com/waypoint-audio-guide")
|
||||||
|
xs.attribute("", "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||||
|
xs.attribute(
|
||||||
|
"", "xsi:schemaLocation",
|
||||||
|
"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
|
||||||
|
)
|
||||||
|
xs.text("\n")
|
||||||
|
|
||||||
|
// metadata
|
||||||
|
xs.startTag("", "metadata")
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("", "name")
|
||||||
|
xs.text("GPS2Audio Export")
|
||||||
|
xs.endTag("", "name")
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("", "time")
|
||||||
|
val isoDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).format(Date())
|
||||||
|
xs.text(isoDate)
|
||||||
|
xs.endTag("", "time")
|
||||||
|
xs.text("\n")
|
||||||
|
xs.endTag("", "metadata")
|
||||||
|
xs.text("\n")
|
||||||
|
|
||||||
|
for (wp in waypoints) {
|
||||||
|
xs.startTag("", "wpt")
|
||||||
|
xs.attribute("", "lat", wp.latitude.toString())
|
||||||
|
xs.attribute("", "lon", wp.longitude.toString())
|
||||||
|
xs.text("\n ")
|
||||||
|
|
||||||
|
xs.startTag("", "name")
|
||||||
|
xs.text(wp.name)
|
||||||
|
xs.endTag("", "name")
|
||||||
|
xs.text("\n ")
|
||||||
|
|
||||||
|
// desc: human-readable summary
|
||||||
|
val descParts = mutableListOf("Radius: ${wp.radiusMeters.toInt()}m")
|
||||||
|
if (wp.soundName.isNotBlank()) descParts.add("Sound: ${wp.soundName}")
|
||||||
|
val modeDesc = when (wp.playbackMode) {
|
||||||
|
PlaybackMode.EVERY_ENTRY -> "Abspielen: bei jedem Betreten"
|
||||||
|
PlaybackMode.ONCE -> "Abspielen: nur einmal"
|
||||||
|
PlaybackMode.LIMITED_COUNT -> "Abspielen: max. ${wp.maxPlayCount ?: 1}×"
|
||||||
|
}
|
||||||
|
descParts.add(modeDesc)
|
||||||
|
descParts.add("Tour: ${wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }}")
|
||||||
|
xs.startTag("", "desc")
|
||||||
|
xs.text(descParts.joinToString(", "))
|
||||||
|
xs.endTag("", "desc")
|
||||||
|
xs.text("\n ")
|
||||||
|
|
||||||
|
// extensions: machine-readable fields for round-trip
|
||||||
|
xs.startTag("", "extensions")
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "id")
|
||||||
|
xs.text(wp.id)
|
||||||
|
xs.endTag("wpa", "id")
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "radius")
|
||||||
|
xs.text(wp.radiusMeters.toString())
|
||||||
|
xs.endTag("wpa", "radius")
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "isActive")
|
||||||
|
xs.text(wp.isActive.toString())
|
||||||
|
xs.endTag("wpa", "isActive")
|
||||||
|
|
||||||
|
// tourName
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "tourName")
|
||||||
|
xs.text(wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME })
|
||||||
|
xs.endTag("wpa", "tourName")
|
||||||
|
|
||||||
|
if (wp.soundName.isNotBlank()) {
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "soundName")
|
||||||
|
xs.text(wp.soundName)
|
||||||
|
xs.endTag("wpa", "soundName")
|
||||||
|
}
|
||||||
|
// soundUri is intentionally NOT exported (content:// URIs are device-local)
|
||||||
|
|
||||||
|
// --- Abspielregel-Felder ---
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "playbackMode")
|
||||||
|
xs.text(wp.playbackMode.name)
|
||||||
|
xs.endTag("wpa", "playbackMode")
|
||||||
|
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "playCount")
|
||||||
|
xs.text(wp.playCount.toString())
|
||||||
|
xs.endTag("wpa", "playCount")
|
||||||
|
|
||||||
|
wp.maxPlayCount?.let { max ->
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "maxPlayCount")
|
||||||
|
xs.text(max.toString())
|
||||||
|
xs.endTag("wpa", "maxPlayCount")
|
||||||
|
}
|
||||||
|
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "scheduleEnabled")
|
||||||
|
xs.text(wp.scheduleEnabled.toString())
|
||||||
|
xs.endTag("wpa", "scheduleEnabled")
|
||||||
|
|
||||||
|
wp.scheduleStartMillis?.let { start ->
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "scheduleStartMillis")
|
||||||
|
xs.text(start.toString())
|
||||||
|
xs.endTag("wpa", "scheduleStartMillis")
|
||||||
|
}
|
||||||
|
|
||||||
|
wp.scheduleEndMillis?.let { end ->
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "scheduleEndMillis")
|
||||||
|
xs.text(end.toString())
|
||||||
|
xs.endTag("wpa", "scheduleEndMillis")
|
||||||
|
}
|
||||||
|
|
||||||
|
wp.allowedStartMinutes?.let { startMin ->
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "allowedStartMinutes")
|
||||||
|
xs.text(startMin.toString())
|
||||||
|
xs.endTag("wpa", "allowedStartMinutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
wp.allowedEndMinutes?.let { endMin ->
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.startTag("wpa", "allowedEndMinutes")
|
||||||
|
xs.text(endMin.toString())
|
||||||
|
xs.endTag("wpa", "allowedEndMinutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
xs.text("\n ")
|
||||||
|
xs.endTag("", "extensions")
|
||||||
|
xs.text("\n")
|
||||||
|
|
||||||
|
xs.endTag("", "wpt")
|
||||||
|
xs.text("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
xs.endTag("", "gpx")
|
||||||
|
xs.endDocument()
|
||||||
|
return sw.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt GPX in einen URI (via ContentResolver).
|
||||||
|
* @return Fehlermeldung oder null bei Erfolg.
|
||||||
|
*/
|
||||||
|
fun exportGpx(context: Context, uri: Uri, waypoints: List<Waypoint>): String? {
|
||||||
|
return runCatching {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||||
|
out.writer().use { it.write(toGpx(waypoints)) }
|
||||||
|
} ?: return "Konnte Datei nicht öffnen: $uri"
|
||||||
|
}.exceptionOrNull()?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GPX Import
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest Wegpunkte aus einer GPX 1.1 Datei.
|
||||||
|
* Felder ohne wpa:extensions erhalten Standardwerte (Radius 50m, kein Sound).
|
||||||
|
* tourName ist optional; fehlt es, gilt [Waypoint.DEFAULT_TOUR_NAME].
|
||||||
|
* @return Pair(liste, fehler)
|
||||||
|
*/
|
||||||
|
fun importGpx(context: Context, uri: Uri): Pair<List<Waypoint>, String?> {
|
||||||
|
return runCatching<Pair<List<Waypoint>, String?>> {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
?: return Pair(emptyList<Waypoint>(), "GPX-Datei konnte nicht geöffnet werden")
|
||||||
|
|
||||||
|
val factory = XmlPullParserFactory.newInstance()
|
||||||
|
factory.isNamespaceAware = true
|
||||||
|
val parser: XmlPullParser = factory.newPullParser()
|
||||||
|
parser.setInput(inputStream, null)
|
||||||
|
|
||||||
|
val waypoints = mutableListOf<Waypoint>()
|
||||||
|
|
||||||
|
// Current wpt fields
|
||||||
|
var inWpt = false
|
||||||
|
var inExtensions = false
|
||||||
|
var lat = Double.NaN
|
||||||
|
var lon = Double.NaN
|
||||||
|
var name = ""
|
||||||
|
var id = ""
|
||||||
|
var radius = 50f
|
||||||
|
var isActive = true
|
||||||
|
var soundName = ""
|
||||||
|
var tourName = ""
|
||||||
|
|
||||||
|
// Abspielregel-Felder
|
||||||
|
var playbackModeStr = ""
|
||||||
|
var playCount = 0
|
||||||
|
var maxPlayCount: Int? = null
|
||||||
|
var scheduleEnabled = false
|
||||||
|
var scheduleStartMillis: Long? = null
|
||||||
|
var scheduleEndMillis: Long? = null
|
||||||
|
var allowedStartMinutes: Int? = null
|
||||||
|
var allowedEndMinutes: Int? = null
|
||||||
|
|
||||||
|
var currentTag = ""
|
||||||
|
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (event) {
|
||||||
|
XmlPullParser.START_TAG -> {
|
||||||
|
val tag = parser.name ?: ""
|
||||||
|
currentTag = tag
|
||||||
|
when {
|
||||||
|
tag == "wpt" -> {
|
||||||
|
inWpt = true
|
||||||
|
inExtensions = false
|
||||||
|
lat = parser.getAttributeValue(null, "lat")?.toDoubleOrNull() ?: Double.NaN
|
||||||
|
lon = parser.getAttributeValue(null, "lon")?.toDoubleOrNull() ?: Double.NaN
|
||||||
|
// Reset all fields
|
||||||
|
name = ""; id = ""; radius = 50f; isActive = true; soundName = ""
|
||||||
|
tourName = ""
|
||||||
|
playbackModeStr = ""; playCount = 0; maxPlayCount = null
|
||||||
|
scheduleEnabled = false
|
||||||
|
scheduleStartMillis = null; scheduleEndMillis = null
|
||||||
|
allowedStartMinutes = null; allowedEndMinutes = null
|
||||||
|
}
|
||||||
|
tag == "extensions" && inWpt -> inExtensions = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlPullParser.TEXT -> {
|
||||||
|
val text = parser.text?.trim() ?: ""
|
||||||
|
if (inWpt && text.isNotEmpty()) {
|
||||||
|
when {
|
||||||
|
currentTag == "name" && !inExtensions -> name = text
|
||||||
|
currentTag == "id" && inExtensions -> id = text
|
||||||
|
currentTag == "radius" && inExtensions -> radius = text.toFloatOrNull() ?: 50f
|
||||||
|
currentTag == "isActive" && inExtensions -> isActive = text.toBooleanStrictOrNull() ?: true
|
||||||
|
currentTag == "soundName" && inExtensions -> soundName = text
|
||||||
|
currentTag == "tourName" && inExtensions -> tourName = text
|
||||||
|
// Abspielregel-Felder
|
||||||
|
currentTag == "playbackMode" && inExtensions -> playbackModeStr = text
|
||||||
|
currentTag == "playCount" && inExtensions -> playCount = text.toIntOrNull() ?: 0
|
||||||
|
currentTag == "maxPlayCount" && inExtensions -> maxPlayCount = text.toIntOrNull()
|
||||||
|
currentTag == "scheduleEnabled" && inExtensions -> scheduleEnabled = text.toBooleanStrictOrNull() ?: false
|
||||||
|
currentTag == "scheduleStartMillis" && inExtensions -> scheduleStartMillis = text.toLongOrNull()
|
||||||
|
currentTag == "scheduleEndMillis" && inExtensions -> scheduleEndMillis = text.toLongOrNull()
|
||||||
|
currentTag == "allowedStartMinutes" && inExtensions -> allowedStartMinutes = text.toIntOrNull()
|
||||||
|
currentTag == "allowedEndMinutes" && inExtensions -> allowedEndMinutes = text.toIntOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlPullParser.END_TAG -> {
|
||||||
|
val tag = parser.name ?: ""
|
||||||
|
when {
|
||||||
|
tag == "extensions" -> inExtensions = false
|
||||||
|
tag == "wpt" -> {
|
||||||
|
if (!lat.isNaN() && !lon.isNaN()) {
|
||||||
|
val mode = runCatching {
|
||||||
|
if (playbackModeStr.isBlank()) PlaybackMode.EVERY_ENTRY
|
||||||
|
else PlaybackMode.valueOf(playbackModeStr)
|
||||||
|
}.getOrDefault(PlaybackMode.EVERY_ENTRY)
|
||||||
|
|
||||||
|
waypoints.add(
|
||||||
|
Waypoint(
|
||||||
|
id = id.ifBlank { UUID.randomUUID().toString() },
|
||||||
|
name = name.ifBlank { "Wegpunkt ${waypoints.size + 1}" },
|
||||||
|
latitude = lat,
|
||||||
|
longitude = lon,
|
||||||
|
radiusMeters = radius.coerceAtLeast(1f),
|
||||||
|
soundUri = "", // content:// URIs cannot be transferred
|
||||||
|
soundName = soundName,
|
||||||
|
isActive = isActive,
|
||||||
|
tourName = tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME },
|
||||||
|
playbackMode = mode,
|
||||||
|
playCount = playCount.coerceAtLeast(0),
|
||||||
|
maxPlayCount = maxPlayCount,
|
||||||
|
scheduleEnabled = scheduleEnabled,
|
||||||
|
scheduleStartMillis = scheduleStartMillis,
|
||||||
|
scheduleEndMillis = scheduleEndMillis,
|
||||||
|
allowedStartMinutes = allowedStartMinutes,
|
||||||
|
allowedEndMinutes = allowedEndMinutes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inWpt = false
|
||||||
|
currentTag = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inExtensions && !inWpt) currentTag = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waypoints.isEmpty()) {
|
||||||
|
Pair(emptyList<Waypoint>(), "GPX enthält keine gültigen Wegpunkte (<wpt> mit lat/lon)")
|
||||||
|
} else {
|
||||||
|
Pair(waypoints, null)
|
||||||
|
}
|
||||||
|
}.getOrElse { e ->
|
||||||
|
Pair(emptyList<Waypoint>(), "GPX-Fehler: ${e.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.waypointaudio.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import de.waypointaudio.data.GpsTrackPoint
|
||||||
|
import de.waypointaudio.data.TourPlaybackDefaults
|
||||||
|
import de.waypointaudio.data.Waypoint
|
||||||
|
import de.waypointaudio.data.WaypointStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository-Schicht für Wegpunkte und Touren.
|
||||||
|
* Abstrahiert den DataStore-Zugriff von UI und Service.
|
||||||
|
*/
|
||||||
|
class WaypointRepository(context: Context) {
|
||||||
|
|
||||||
|
private val store = WaypointStore(context)
|
||||||
|
|
||||||
|
/** Reaktiver Flow der Wegpunktliste. */
|
||||||
|
val waypoints: Flow<List<Waypoint>> = store.waypointsFlow
|
||||||
|
|
||||||
|
/** Reaktiver Flow der persistierten Tourliste. */
|
||||||
|
val tours: Flow<List<String>> = store.toursFlow
|
||||||
|
|
||||||
|
/** Reaktiver Flow der Tour-weiten Abspiel-Vorgaben. */
|
||||||
|
val tourDefaults: Flow<Map<String, TourPlaybackDefaults>> = store.tourDefaultsFlow
|
||||||
|
|
||||||
|
/** Reaktiver Flow der gespeicherten GPS-Tracks. */
|
||||||
|
val gpsTracks: Flow<Map<String, List<GpsTrackPoint>>> = store.gpsTracksFlow
|
||||||
|
|
||||||
|
/** Wegpunkt hinzufügen oder aktualisieren. */
|
||||||
|
suspend fun upsert(waypoint: Waypoint) = store.upsert(waypoint)
|
||||||
|
|
||||||
|
/** Wegpunkt löschen. */
|
||||||
|
suspend fun delete(id: String) = store.delete(id)
|
||||||
|
|
||||||
|
/** Alle Wegpunkte ersetzen. */
|
||||||
|
suspend fun saveAll(waypoints: List<Waypoint>) = store.saveAll(waypoints)
|
||||||
|
|
||||||
|
/** Tourliste persistieren. */
|
||||||
|
suspend fun saveTours(tours: List<String>) = store.saveTours(tours)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inkrementiert den playCount eines Wegpunkts atomar nach erfolgreicher Wiedergabe.
|
||||||
|
* @return den aktualisierten Wegpunkt oder null wenn nicht gefunden.
|
||||||
|
*/
|
||||||
|
suspend fun incrementPlayCount(waypointId: String): Waypoint? =
|
||||||
|
store.incrementPlayCount(waypointId)
|
||||||
|
|
||||||
|
/** Setzt den playCount aller Wegpunkte einer Tour auf 0 zurück. */
|
||||||
|
suspend fun resetPlayCountsForTour(tourName: String): Int =
|
||||||
|
store.resetPlayCountsForTour(tourName)
|
||||||
|
|
||||||
|
/** Wendet einen Abspiel-Modus auf alle Wegpunkte einer Tour an. */
|
||||||
|
suspend fun applyPlaybackModeForTour(
|
||||||
|
tourName: String,
|
||||||
|
mode: de.waypointaudio.data.PlaybackMode,
|
||||||
|
maxPlayCount: Int?,
|
||||||
|
resetCounts: Boolean
|
||||||
|
): Int = store.applyPlaybackModeForTour(tourName, mode, maxPlayCount, resetCounts)
|
||||||
|
|
||||||
|
/** Speichert die Tour-weiten Abspiel-Vorgaben. */
|
||||||
|
suspend fun saveTourDefaults(tourName: String, defaults: TourPlaybackDefaults) =
|
||||||
|
store.saveTourDefaults(tourName, defaults)
|
||||||
|
|
||||||
|
/** Liest die Tour-weiten Abspiel-Vorgaben einmalig. */
|
||||||
|
suspend fun getTourDefaults(tourName: String): TourPlaybackDefaults =
|
||||||
|
store.getTourDefaults(tourName)
|
||||||
|
|
||||||
|
/** Speichert den GPS-Track einer Tour (persistiert). */
|
||||||
|
suspend fun saveGpsTrack(tourName: String, points: List<GpsTrackPoint>) =
|
||||||
|
store.saveGpsTrack(tourName, points)
|
||||||
|
|
||||||
|
/** Löscht den GPS-Track einer Tour. */
|
||||||
|
suspend fun clearGpsTrack(tourName: String) =
|
||||||
|
store.clearGpsTrack(tourName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vereinfachtes Modell für ein Audio-Gerät, das in der UI angezeigt werden kann.
|
||||||
|
*
|
||||||
|
* @param id AudioDeviceInfo.id (Sitzungs-spezifisch, kann sich nach Neustart ändern)
|
||||||
|
* @param displayName Lesbarer Name auf Deutsch
|
||||||
|
* @param isInput true = Eingabegerät (Mikrofon), false = Ausgabegerät
|
||||||
|
* @param rawType AudioDeviceInfo.type, für interne Logik
|
||||||
|
*/
|
||||||
|
data class AudioDeviceItem(
|
||||||
|
val id: Int,
|
||||||
|
val displayName: String,
|
||||||
|
val isInput: Boolean,
|
||||||
|
val rawType: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilfsklasse zum Auflisten verfügbarer Audio-Eingabe- und Ausgabegeräte.
|
||||||
|
*
|
||||||
|
* Hinweis: Die zurückgegebenen Geräte-IDs (AudioDeviceInfo.id) sind sitzungsspezifisch.
|
||||||
|
* Wenn ein gespeichertes Gerät nicht mehr in der Liste erscheint, wird der Systemstandard
|
||||||
|
* verwendet.
|
||||||
|
*/
|
||||||
|
object AudioDeviceManager {
|
||||||
|
|
||||||
|
private const val TAG = "AudioDeviceManager"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle verfügbaren Eingabegeräte zurück (Mikrofone, Bluetooth-Headsets usw.).
|
||||||
|
* Die Liste enthält keine System-internen Geräte (REMOTE_SUBMIX etc.).
|
||||||
|
*/
|
||||||
|
fun getInputDevices(context: Context): List<AudioDeviceItem> {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
return am.getDevices(AudioManager.GET_DEVICES_INPUTS)
|
||||||
|
.filter { isUsefulInputDevice(it.type) }
|
||||||
|
.map { deviceToItem(it, isInput = true) }
|
||||||
|
.also { Log.d(TAG, "Eingabegeräte: ${it.size}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle verfügbaren Ausgabegeräte zurück (Lautsprecher, Kopfhörer, Bluetooth usw.).
|
||||||
|
*/
|
||||||
|
fun getOutputDevices(context: Context): List<AudioDeviceItem> {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
return am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
.filter { isUsefulOutputDevice(it.type) }
|
||||||
|
.map { deviceToItem(it, isInput = false) }
|
||||||
|
.also { Log.d(TAG, "Ausgabegeräte: ${it.size}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht ein Eingabegerät anhand seiner ID. Gibt null zurück wenn nicht gefunden.
|
||||||
|
*/
|
||||||
|
fun findInputDevice(context: Context, deviceId: Int): AudioDeviceInfo? {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
return am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { it.id == deviceId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht ein Ausgabegerät anhand seiner ID. Gibt null zurück wenn nicht gefunden.
|
||||||
|
*/
|
||||||
|
fun findOutputDevice(context: Context, deviceId: Int): AudioDeviceInfo? {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
return am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { it.id == deviceId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private Hilfsfunktionen ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun deviceToItem(info: AudioDeviceInfo, isInput: Boolean): AudioDeviceItem {
|
||||||
|
val name = buildDisplayName(info)
|
||||||
|
return AudioDeviceItem(
|
||||||
|
id = info.id,
|
||||||
|
displayName = name,
|
||||||
|
isInput = isInput,
|
||||||
|
rawType = info.type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDisplayName(info: AudioDeviceInfo): String {
|
||||||
|
val typeName = germanTypeName(info.type)
|
||||||
|
// If the device has a product name, append it for disambiguation
|
||||||
|
val productName = try {
|
||||||
|
info.productName?.toString()?.trim()
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
return if (!productName.isNullOrBlank() && productName != typeName) {
|
||||||
|
"$typeName – $productName"
|
||||||
|
} else {
|
||||||
|
typeName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert einen verständlichen deutschen Namen für den AudioDeviceInfo.type.
|
||||||
|
*/
|
||||||
|
fun germanTypeName(type: Int): String = when (type) {
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internes Mikrofon"
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Hörmuschel"
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Lautsprecher"
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Kabelgebundenes Headset"
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Kabelgebundene Kopfhörer"
|
||||||
|
AudioDeviceInfo.TYPE_LINE_ANALOG -> "Analoger Line-Ausgang"
|
||||||
|
AudioDeviceInfo.TYPE_LINE_DIGITAL -> "Digitaler Line-Ausgang"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth (SCO/Telefon)"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth (A2DP/Stereo)"
|
||||||
|
AudioDeviceInfo.TYPE_HDMI -> "HDMI"
|
||||||
|
AudioDeviceInfo.TYPE_HDMI_ARC -> "HDMI ARC"
|
||||||
|
AudioDeviceInfo.TYPE_USB_DEVICE -> "USB-Gerät"
|
||||||
|
AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB-Zubehör"
|
||||||
|
AudioDeviceInfo.TYPE_USB_HEADSET -> "USB-Headset"
|
||||||
|
AudioDeviceInfo.TYPE_TELEPHONY -> "Telefon"
|
||||||
|
AudioDeviceInfo.TYPE_IP -> "IP-Audio"
|
||||||
|
AudioDeviceInfo.TYPE_BUS -> "Bus-Audio"
|
||||||
|
AudioDeviceInfo.TYPE_FM -> "FM"
|
||||||
|
AudioDeviceInfo.TYPE_FM_TUNER -> "FM-Tuner"
|
||||||
|
AudioDeviceInfo.TYPE_TV_TUNER -> "TV-Tuner"
|
||||||
|
AudioDeviceInfo.TYPE_DOCK -> "Dockingstation"
|
||||||
|
AudioDeviceInfo.TYPE_REMOTE_SUBMIX -> "Remote-Submix (intern)"
|
||||||
|
AudioDeviceInfo.TYPE_UNKNOWN -> "Unbekanntes Gerät"
|
||||||
|
else -> "Audiogerät (Typ $type)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsefulInputDevice(type: Int): Boolean = when (type) {
|
||||||
|
AudioDeviceInfo.TYPE_REMOTE_SUBMIX,
|
||||||
|
AudioDeviceInfo.TYPE_TELEPHONY,
|
||||||
|
AudioDeviceInfo.TYPE_FM_TUNER,
|
||||||
|
AudioDeviceInfo.TYPE_TV_TUNER -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsefulOutputDevice(type: Int): Boolean = when (type) {
|
||||||
|
AudioDeviceInfo.TYPE_REMOTE_SUBMIX,
|
||||||
|
AudioDeviceInfo.TYPE_TELEPHONY,
|
||||||
|
AudioDeviceInfo.TYPE_FM,
|
||||||
|
AudioDeviceInfo.TYPE_FM_TUNER,
|
||||||
|
AudioDeviceInfo.TYPE_TV_TUNER -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einfacher Audio-Player für Wegpunkt-Sounds (GPS-Dienst).
|
||||||
|
* Nutzt Android MediaPlayer; spielt Content-URIs oder File-URIs ab.
|
||||||
|
*
|
||||||
|
* Completion- und Error-Callbacks erlauben dem Aufrufer, exakt nach dem
|
||||||
|
* Ende der Wiedergabe (oder bei einem Fehler) auf das Ereignis zu reagieren –
|
||||||
|
* z. B. um die Begleitmusik korrekt wiederherzustellen.
|
||||||
|
*
|
||||||
|
* Thread-Sicherheit: Die Callbacks werden auf dem MediaPlayer-internen Thread
|
||||||
|
* aufgerufen. Der Aufrufer ist dafür verantwortlich, ggf. in den richtigen
|
||||||
|
* Dispatcher zu wechseln (z. B. Dispatchers.Main für ExoPlayer-Zugriffe).
|
||||||
|
*/
|
||||||
|
class AudioPlayer {
|
||||||
|
|
||||||
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt eine Audiodatei ab.
|
||||||
|
*
|
||||||
|
* @param context Android-Context
|
||||||
|
* @param soundUri URI-String (content:// oder file://)
|
||||||
|
* @param onCompletion Wird aufgerufen, wenn die Wiedergabe natürlich endet.
|
||||||
|
* Wird auf dem MediaPlayer-Thread aufgerufen.
|
||||||
|
* @param onError Wird aufgerufen, wenn die Wiedergabe nicht gestartet
|
||||||
|
* werden kann oder ein MediaPlayer-Fehler auftritt.
|
||||||
|
* Wird auf dem MediaPlayer-Thread (oder dem Aufruf-Thread
|
||||||
|
* bei Initialisierungsfehlern) aufgerufen.
|
||||||
|
*/
|
||||||
|
fun play(
|
||||||
|
context: Context,
|
||||||
|
soundUri: String,
|
||||||
|
onCompletion: (() -> Unit)? = null,
|
||||||
|
onError: ((Exception?) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
if (soundUri.isBlank()) {
|
||||||
|
Log.w(TAG, "Keine Audiodatei angegeben.")
|
||||||
|
onError?.invoke(IllegalArgumentException("soundUri ist leer"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() // vorherige Wiedergabe stoppen
|
||||||
|
|
||||||
|
// callbackFired verhindert Doppel-Callbacks: completion und error
|
||||||
|
// schließen sich gegenseitig aus.
|
||||||
|
var callbackFired = false
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val uri = Uri.parse(soundUri)
|
||||||
|
val mp = MediaPlayer()
|
||||||
|
mediaPlayer = mp
|
||||||
|
mp.setDataSource(context, uri)
|
||||||
|
mp.prepare()
|
||||||
|
|
||||||
|
mp.setOnCompletionListener { finishedMp ->
|
||||||
|
if (!callbackFired) {
|
||||||
|
callbackFired = true
|
||||||
|
finishedMp.release()
|
||||||
|
if (mediaPlayer === finishedMp) mediaPlayer = null
|
||||||
|
Log.d(TAG, "Wiedergabe beendet: $soundUri")
|
||||||
|
onCompletion?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.setOnErrorListener { errorMp, what, extra ->
|
||||||
|
if (!callbackFired) {
|
||||||
|
callbackFired = true
|
||||||
|
errorMp.release()
|
||||||
|
if (mediaPlayer === errorMp) mediaPlayer = null
|
||||||
|
val msg = "MediaPlayer-Fehler: what=$what extra=$extra"
|
||||||
|
Log.e(TAG, msg)
|
||||||
|
onError?.invoke(RuntimeException(msg))
|
||||||
|
}
|
||||||
|
true // Fehler wurde behandelt → kein zusätzlicher onCompletion-Aufruf
|
||||||
|
}
|
||||||
|
|
||||||
|
mp.start()
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
|
||||||
|
// Ressource freigeben falls teilweise initialisiert
|
||||||
|
runCatching { mediaPlayer?.release() }
|
||||||
|
mediaPlayer = null
|
||||||
|
if (!callbackFired) {
|
||||||
|
callbackFired = true
|
||||||
|
onError?.invoke(e as? Exception ?: RuntimeException(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stoppt die aktuelle Wiedergabe und gibt Ressourcen frei. */
|
||||||
|
fun stop() {
|
||||||
|
runCatching {
|
||||||
|
mediaPlayer?.let {
|
||||||
|
if (it.isPlaying) it.stop()
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaPlayer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AudioPlayer"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import de.waypointaudio.data.TourAudioSettings
|
||||||
|
import de.waypointaudio.data.TourMusicStore
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-weiter Singleton-Manager für die Begleitmusik.
|
||||||
|
*
|
||||||
|
* Stellt einen zentralen [BackgroundMusicPlayer] bereit, der sowohl vom ViewModel
|
||||||
|
* (manuelle Wiedergabe) als auch vom [WaypointLocationService] (GPS-Trigger) genutzt wird.
|
||||||
|
*
|
||||||
|
* Der Manager überbrückt den App-Prozess – Service und ViewModel teilen sich dieselbe Instanz.
|
||||||
|
*
|
||||||
|
* Lifecycle: Wird in [de.waypointaudio.WaypointApp] initialisiert und läuft für die gesamte
|
||||||
|
* Prozess-Lifetime. Release erfolgt in [release].
|
||||||
|
*/
|
||||||
|
class BackgroundMusicManager(private val appContext: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackgroundMusicManager"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: BackgroundMusicManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): BackgroundMusicManager {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: BackgroundMusicManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||||
|
Log.e(TAG, "Unbehandelte Ausnahme im BackgroundMusicManager", throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
|
||||||
|
private val store = TourMusicStore(appContext)
|
||||||
|
|
||||||
|
/** Der verwaltete Player. Läuft auf dem Main-Thread. */
|
||||||
|
val player = BackgroundMusicPlayer(appContext)
|
||||||
|
|
||||||
|
/** Aktuell aktive Tour-Einstellungen (gecacht nach letztem [loadTour]). */
|
||||||
|
var currentSettings: TourAudioSettings = TourAudioSettings()
|
||||||
|
|
||||||
|
/** Aktueller Tourname. */
|
||||||
|
var currentTourName: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Öffentliche API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Einstellungen für eine Tour und bereitet den Player vor.
|
||||||
|
* Stoppt ggf. laufende Musik der vorherigen Tour.
|
||||||
|
* Lädt immer neu aus dem Store (auch wenn dieselbe Tour nochmals geladen wird),
|
||||||
|
* damit nach Konfigurationsänderungen die aktuellen Einstellungen gelten.
|
||||||
|
*
|
||||||
|
* @param tourName Name der Tour
|
||||||
|
* @param force true = Einstellungen auch bei gleicher Tour neu laden (z. B. nach Speichern)
|
||||||
|
*/
|
||||||
|
fun loadTour(tourName: String, force: Boolean = false) {
|
||||||
|
if (currentTourName == tourName && !force) return
|
||||||
|
val previousTour = currentTourName
|
||||||
|
currentTourName = tourName
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val settings = store.settingsForTour(tourName).first()
|
||||||
|
currentSettings = settings
|
||||||
|
player.loadSettings(settings)
|
||||||
|
if (!settings.enabled) {
|
||||||
|
// Musik stoppen wenn Tour gewechselt (nicht wenn gleiche Tour ohne Aktivierung)
|
||||||
|
if (previousTour != tourName) {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Tour geladen: $tourName, Begleitmusik aktiviert=${settings.enabled}, autoStart=${settings.autoStartAfterWaypoint}")
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Laden der Tour '$tourName'", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Einstellungen neu und wendet sie an (z. B. nach Dialog-Speichern).
|
||||||
|
*/
|
||||||
|
fun reloadCurrentTour() {
|
||||||
|
loadTour(currentTourName, force = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet die Begleitmusik für die aktuelle Tour (wenn aktiviert).
|
||||||
|
*/
|
||||||
|
fun startMusic() {
|
||||||
|
val s = currentSettings
|
||||||
|
if (!s.enabled) return
|
||||||
|
player.loadSettings(s)
|
||||||
|
player.play()
|
||||||
|
Log.i(TAG, "Begleitmusik gestartet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt die Begleitmusik sofort.
|
||||||
|
*/
|
||||||
|
fun stopMusic() {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pausiert die Begleitmusik.
|
||||||
|
*/
|
||||||
|
fun pauseMusic() {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nächster Titel in der Playlist (nur für lokale Playlists).
|
||||||
|
*/
|
||||||
|
fun nextTrack() {
|
||||||
|
player.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorheriger Titel in der Playlist (nur für lokale Playlists).
|
||||||
|
*/
|
||||||
|
fun previousTrack() {
|
||||||
|
player.previous()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert neue Einstellungen für eine Tour und wendet sie direkt an.
|
||||||
|
*/
|
||||||
|
fun saveAndApplySettings(tourName: String, settings: TourAudioSettings) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
store.save(tourName, settings)
|
||||||
|
if (tourName == currentTourName) {
|
||||||
|
currentSettings = settings
|
||||||
|
player.loadSettings(settings)
|
||||||
|
if (!settings.enabled) {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Speichern der Einstellungen für Tour '$tourName'", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest Einstellungen aus dem Store (suspend).
|
||||||
|
*/
|
||||||
|
suspend fun loadSettingsForTour(tourName: String): TourAudioSettings =
|
||||||
|
store.settingsForTour(tourName).first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vor Wegpunkt-Audio: Verhalten anwenden.
|
||||||
|
*/
|
||||||
|
fun beforeWaypointAudio() {
|
||||||
|
player.beforeWaypointAudio(currentSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach Wegpunkt-Audio: Verhalten rückgängig machen / autostart anwenden.
|
||||||
|
*/
|
||||||
|
fun afterWaypointAudio() {
|
||||||
|
player.afterWaypointAudio(currentSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle Ressourcen frei. Danach nicht mehr nutzbar.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
scope.cancel()
|
||||||
|
player.release()
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
|
import de.waypointaudio.data.MusicSourceType
|
||||||
|
import de.waypointaudio.data.PlaylistItem
|
||||||
|
import de.waypointaudio.data.TourAudioSettings
|
||||||
|
import de.waypointaudio.data.WaypointMusicBehavior
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand des Begleitmusik-Players, der in der UI angezeigt werden kann.
|
||||||
|
*/
|
||||||
|
data class MusicPlaybackState(
|
||||||
|
/** Ob gerade Musik abgespielt wird. */
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
/** Ob ein Track geladen ist (auch wenn pausiert). */
|
||||||
|
val hasContent: Boolean = false,
|
||||||
|
/** Titel des aktuellen Tracks (displayName oder URL). */
|
||||||
|
val currentTitle: String = "",
|
||||||
|
/** Kurzform der Quelle (z. B. "Playlist" oder Stream-URL-Hostname). */
|
||||||
|
val sourceLabel: String = "",
|
||||||
|
/** Ob die Quelle ein Stream ist (keine endliche Länge). */
|
||||||
|
val isStream: Boolean = false,
|
||||||
|
/** Wiedergabe-Position in Millisekunden (0 für Streams). */
|
||||||
|
val positionMs: Long = 0L,
|
||||||
|
/** Gesamtlänge in Millisekunden (0 für Streams oder unbekannt). */
|
||||||
|
val durationMs: Long = 0L,
|
||||||
|
/** Index des aktuellen Tracks in der Playlist (0-basiert, -1 wenn unbekannt). */
|
||||||
|
val playlistIndex: Int = -1,
|
||||||
|
/** Gesamtanzahl Tracks in der Playlist. */
|
||||||
|
val playlistTotal: Int = 0,
|
||||||
|
/** Titel des nächsten Tracks (leer wenn nicht verfügbar). */
|
||||||
|
val nextTitle: String = "",
|
||||||
|
/** Ob Previous/Next sinnvoll sind (nur lokale Playlists mit >1 Track). */
|
||||||
|
val supportsSkip: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begleitmusik-Player auf Basis von AndroidX Media3 ExoPlayer.
|
||||||
|
*
|
||||||
|
* Unterstützt:
|
||||||
|
* - Lokale Playlists via content:// URIs (persistable permissions werden beim Abspielen gesichert)
|
||||||
|
* - Direkte HTTP/HTTPS Audio-Stream-URLs
|
||||||
|
* - Fade-Out/In, Duck (Lautstärke reduzieren), Pause/Resume, Weiter-Spielen
|
||||||
|
* - Optionaler Auto-Start nach Wegpunkt-Audio (autoStartAfterWaypoint)
|
||||||
|
*
|
||||||
|
* Lifecycle: Instanz an ViewModel oder Application koppeln; [release] beim Aufräumen aufrufen.
|
||||||
|
*
|
||||||
|
* Thread-Sicherheit: ExoPlayer-Zugriffe müssen auf dem Main-Thread erfolgen.
|
||||||
|
* Alle öffentlichen Methoden müssen aus dem Main-Dispatcher aufgerufen werden.
|
||||||
|
*/
|
||||||
|
class BackgroundMusicPlayer(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackgroundMusicPlayer"
|
||||||
|
private const val FADE_STEP_INTERVAL_MS = 50L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var player: ExoPlayer? = null
|
||||||
|
private var fadeJob: Job? = null
|
||||||
|
private var progressJob: Job? = null
|
||||||
|
|
||||||
|
// Aktuell geladene Einstellungen
|
||||||
|
private var currentSettings: TourAudioSettings? = null
|
||||||
|
|
||||||
|
// Ziel-Lautstärke im Normalbetrieb (vor Duck/Fade)
|
||||||
|
private var normalVolume: Float = 1.0f
|
||||||
|
|
||||||
|
// True wenn Musik gerade wegen Wegpunkt-Audio unterdrückt wird
|
||||||
|
private var waypointSuppressed: Boolean = false
|
||||||
|
|
||||||
|
// True wenn Player war aktiv bevor Waypoint ausgelöst wurde
|
||||||
|
private var wasPlayingBeforeWaypoint: Boolean = false
|
||||||
|
|
||||||
|
// Snapshot-Liste der geladenen PlaylistItems (für Titel-Lookup)
|
||||||
|
private var loadedPlaylist: List<PlaylistItem> = emptyList()
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Öffentlicher State-Flow
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private val _playbackState = MutableStateFlow(MusicPlaybackState())
|
||||||
|
/** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */
|
||||||
|
val playbackState: StateFlow<MusicPlaybackState> = _playbackState.asStateFlow()
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Öffentliche API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */
|
||||||
|
val isPlaying: Boolean
|
||||||
|
get() = player?.isPlaying == true
|
||||||
|
|
||||||
|
/** Ob ein Track geladen ist (auch wenn pausiert). */
|
||||||
|
val hasContent: Boolean
|
||||||
|
get() = player != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt neue Einstellungen und bereitet den Player vor.
|
||||||
|
* Stoppt zuvor laufende Wiedergabe wenn Quelle sich geändert hat.
|
||||||
|
*/
|
||||||
|
fun loadSettings(settings: TourAudioSettings) {
|
||||||
|
currentSettings = settings
|
||||||
|
normalVolume = 1.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet Wiedergabe mit den aktuell geladenen Einstellungen.
|
||||||
|
* Baut ExoPlayer auf wenn nötig.
|
||||||
|
*/
|
||||||
|
fun play() {
|
||||||
|
val settings = currentSettings ?: return
|
||||||
|
if (!settings.enabled) return
|
||||||
|
|
||||||
|
if (player == null) {
|
||||||
|
buildPlayer(settings)
|
||||||
|
} else {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
|
||||||
|
fun pause() {
|
||||||
|
cancelFade()
|
||||||
|
player?.pause()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stoppt Wiedergabe und gibt ExoPlayer frei. */
|
||||||
|
fun stop() {
|
||||||
|
cancelFade()
|
||||||
|
releasePlayer()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nächster Titel in der Playlist (nur für lokale Playlists sinnvoll). */
|
||||||
|
fun next() {
|
||||||
|
val p = player ?: return
|
||||||
|
if (p.hasNextMediaItem()) p.seekToNextMediaItem() else p.seekTo(0, 0)
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorheriger Titel in der Playlist. */
|
||||||
|
fun previous() {
|
||||||
|
val p = player ?: return
|
||||||
|
if (p.hasPreviousMediaItem()) p.seekToPreviousMediaItem() else p.seekTo(0, 0)
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt die Lautstärke sofort (0.0 – 1.0). */
|
||||||
|
fun setVolume(volume: Float) {
|
||||||
|
player?.volume = volume.coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt alle Ressourcen frei. Nach diesem Aufruf ist die Instanz nicht mehr nutzbar. */
|
||||||
|
fun release() {
|
||||||
|
scope.cancel()
|
||||||
|
releasePlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Wegpunkt-Interaktion
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vor dem Abspielen eines Wegpunkt-Audios aufrufen.
|
||||||
|
* Wendet das konfigurierte Verhalten an (Pause, Fade-Out, Duck, Weiter).
|
||||||
|
*/
|
||||||
|
fun beforeWaypointAudio(settings: TourAudioSettings) {
|
||||||
|
if (!settings.enabled) return
|
||||||
|
wasPlayingBeforeWaypoint = isPlaying
|
||||||
|
waypointSuppressed = true
|
||||||
|
|
||||||
|
// Nur wenn player vorhanden (Musik läuft/pausiert)
|
||||||
|
if (player == null) return
|
||||||
|
|
||||||
|
when (settings.behavior) {
|
||||||
|
WaypointMusicBehavior.PAUSE_RESUME -> {
|
||||||
|
if (wasPlayingBeforeWaypoint) pause()
|
||||||
|
}
|
||||||
|
WaypointMusicBehavior.FADE_OUT_IN -> {
|
||||||
|
if (wasPlayingBeforeWaypoint) {
|
||||||
|
fadeOut(settings.fadeDurationMs) {
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WaypointMusicBehavior.DUCK_UNDERLAY -> {
|
||||||
|
if (wasPlayingBeforeWaypoint) {
|
||||||
|
fadeToVolume(settings.duckVolume, settings.fadeDurationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
|
||||||
|
// Nichts tun – Musik läuft auf normaler Lautstärke weiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach dem Abspielen eines Wegpunkt-Audios aufrufen.
|
||||||
|
* Stellt vorherigen Zustand wieder her.
|
||||||
|
* Wenn autoStartAfterWaypoint aktiv und Musik war nicht spielend, wird sie gestartet.
|
||||||
|
*/
|
||||||
|
fun afterWaypointAudio(settings: TourAudioSettings) {
|
||||||
|
if (!settings.enabled) {
|
||||||
|
waypointSuppressed = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val wasSuppressed = waypointSuppressed
|
||||||
|
waypointSuppressed = false
|
||||||
|
|
||||||
|
if (!wasSuppressed) return
|
||||||
|
|
||||||
|
val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint
|
||||||
|
|
||||||
|
when (settings.behavior) {
|
||||||
|
WaypointMusicBehavior.PAUSE_RESUME -> {
|
||||||
|
if (wasPlayingBeforeWaypoint) {
|
||||||
|
player?.play()
|
||||||
|
} else if (shouldAutoStart) {
|
||||||
|
startOrPlay(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WaypointMusicBehavior.FADE_OUT_IN -> {
|
||||||
|
if (wasPlayingBeforeWaypoint) {
|
||||||
|
player?.volume = 0f
|
||||||
|
player?.play()
|
||||||
|
fadeIn(settings.fadeDurationMs)
|
||||||
|
} else if (shouldAutoStart) {
|
||||||
|
startOrPlay(settings)
|
||||||
|
fadeIn(settings.fadeDurationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WaypointMusicBehavior.DUCK_UNDERLAY -> {
|
||||||
|
if (wasPlayingBeforeWaypoint) {
|
||||||
|
fadeToVolume(normalVolume, settings.fadeDurationMs)
|
||||||
|
} else if (shouldAutoStart) {
|
||||||
|
startOrPlay(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
|
||||||
|
if (shouldAutoStart) {
|
||||||
|
startOrPlay(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Interner Aufbau
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet Wiedergabe: baut Player neu wenn noch nicht vorhanden, sonst play().
|
||||||
|
*/
|
||||||
|
private fun startOrPlay(settings: TourAudioSettings) {
|
||||||
|
if (player == null) {
|
||||||
|
buildPlayer(settings)
|
||||||
|
} else {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPlayer(settings: TourAudioSettings) {
|
||||||
|
releasePlayer()
|
||||||
|
|
||||||
|
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
|
||||||
|
val items = when (settings.sourceType) {
|
||||||
|
MusicSourceType.LOCAL_PLAYLIST -> buildLocalItems(settings.localPlaylist)
|
||||||
|
MusicSourceType.STREAM_URL -> buildStreamItems(settings.streamUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Log.w(TAG, "Keine abspielbaren Quellen gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist-Snapshot für Titel-Lookup speichern
|
||||||
|
loadedPlaylist = if (isStream) emptyList() else settings.localPlaylist
|
||||||
|
|
||||||
|
val exo = ExoPlayer.Builder(context).build().also { p ->
|
||||||
|
p.setMediaItems(items)
|
||||||
|
p.repeatMode = Player.REPEAT_MODE_ALL
|
||||||
|
p.shuffleModeEnabled = settings.shuffle
|
||||||
|
p.volume = normalVolume
|
||||||
|
p.addListener(object : Player.Listener {
|
||||||
|
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||||
|
Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error)
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
override fun onIsPlayingChanged(playing: Boolean) {
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
p.prepare()
|
||||||
|
p.play()
|
||||||
|
}
|
||||||
|
player = exo
|
||||||
|
Log.i(TAG, "BackgroundMusicPlayer gestartet (${settings.sourceType})")
|
||||||
|
startProgressPolling()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startet ein Coroutine-Polling für die Wiedergabe-Position (lokale Dateien). */
|
||||||
|
private fun startProgressPolling() {
|
||||||
|
progressJob?.cancel()
|
||||||
|
progressJob = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(500L)
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest den aktuellen Player-Zustand und schreibt ihn in [_playbackState]. */
|
||||||
|
private fun updateState() {
|
||||||
|
val p = player
|
||||||
|
val settings = currentSettings
|
||||||
|
if (p == null || settings == null) {
|
||||||
|
_playbackState.value = MusicPlaybackState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
|
||||||
|
val idx = p.currentMediaItemIndex
|
||||||
|
val total = p.mediaItemCount
|
||||||
|
val playlist = loadedPlaylist
|
||||||
|
|
||||||
|
// Titel des aktuellen Tracks
|
||||||
|
val currentTitle: String = when {
|
||||||
|
isStream -> settings.streamUrl ?: ""
|
||||||
|
playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titel des nächsten Tracks
|
||||||
|
val nextTitle: String = when {
|
||||||
|
isStream -> ""
|
||||||
|
playlist.size > 1 -> {
|
||||||
|
val nextIdx = (idx + 1) % playlist.size
|
||||||
|
if (nextIdx in playlist.indices) playlist[nextIdx].displayName else ""
|
||||||
|
}
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quell-Label
|
||||||
|
val sourceLabel: String = when {
|
||||||
|
isStream -> {
|
||||||
|
val url = settings.streamUrl ?: ""
|
||||||
|
runCatching {
|
||||||
|
java.net.URL(url).host.ifBlank { url }
|
||||||
|
}.getOrElse { url }
|
||||||
|
}
|
||||||
|
else -> "Playlist"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position und Dauer nur für lokale Dateien mit bekannter Länge
|
||||||
|
val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L
|
||||||
|
val durationMs = if (!isStream) {
|
||||||
|
val d = p.duration
|
||||||
|
if (d > 0) d else 0L
|
||||||
|
} else 0L
|
||||||
|
|
||||||
|
_playbackState.value = MusicPlaybackState(
|
||||||
|
isPlaying = p.isPlaying,
|
||||||
|
hasContent = true,
|
||||||
|
currentTitle = currentTitle,
|
||||||
|
sourceLabel = sourceLabel,
|
||||||
|
isStream = isStream,
|
||||||
|
positionMs = positionMs,
|
||||||
|
durationMs = durationMs,
|
||||||
|
playlistIndex = if (!isStream) idx else -1,
|
||||||
|
playlistTotal = if (!isStream) total else 0,
|
||||||
|
nextTitle = nextTitle,
|
||||||
|
supportsSkip = !isStream && total > 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLocalItems(playlist: List<PlaylistItem>): List<MediaItem> {
|
||||||
|
return playlist.mapNotNull { item ->
|
||||||
|
runCatching {
|
||||||
|
val uri = Uri.parse(item.uriString)
|
||||||
|
// Persistable URI-Berechtigung sichern (beste-Effort)
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MediaItem.Builder()
|
||||||
|
.setUri(uri)
|
||||||
|
.build()
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Ungültige URI: ${item.uriString}", e)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStreamItems(streamUrl: String?): List<MediaItem> {
|
||||||
|
if (streamUrl.isNullOrBlank()) return emptyList()
|
||||||
|
val lower = streamUrl.lowercase()
|
||||||
|
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
|
||||||
|
Log.w(TAG, "Stream-URL ist keine gültige http/https-URL: $streamUrl")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return listOf(
|
||||||
|
MediaItem.Builder()
|
||||||
|
.setUri(Uri.parse(streamUrl))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releasePlayer() {
|
||||||
|
cancelFade()
|
||||||
|
progressJob?.cancel()
|
||||||
|
progressJob = null
|
||||||
|
runCatching {
|
||||||
|
player?.stop()
|
||||||
|
player?.release()
|
||||||
|
}
|
||||||
|
player = null
|
||||||
|
loadedPlaylist = emptyList()
|
||||||
|
_playbackState.value = MusicPlaybackState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Fade-Helfer (Koroutinen-basiert)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun cancelFade() {
|
||||||
|
fadeJob?.cancel()
|
||||||
|
fadeJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
|
||||||
|
cancelFade()
|
||||||
|
val startVolume = player?.volume ?: 1f
|
||||||
|
fadeJob = scope.launch {
|
||||||
|
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
|
||||||
|
val stepSize = startVolume / steps
|
||||||
|
var current = startVolume
|
||||||
|
repeat(steps.toInt()) {
|
||||||
|
if (!isActive) return@launch
|
||||||
|
current = (current - stepSize).coerceAtLeast(0f)
|
||||||
|
withContext(Dispatchers.Main) { player?.volume = current }
|
||||||
|
delay(FADE_STEP_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
player?.volume = 0f
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fadeIn(durationMs: Long) {
|
||||||
|
cancelFade()
|
||||||
|
val targetVolume = normalVolume
|
||||||
|
fadeJob = scope.launch {
|
||||||
|
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
|
||||||
|
val stepSize = targetVolume / steps
|
||||||
|
var current = 0f
|
||||||
|
repeat(steps.toInt()) {
|
||||||
|
if (!isActive) return@launch
|
||||||
|
current = (current + stepSize).coerceAtMost(targetVolume)
|
||||||
|
withContext(Dispatchers.Main) { player?.volume = current }
|
||||||
|
delay(FADE_STEP_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { player?.volume = targetVolume }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
|
||||||
|
cancelFade()
|
||||||
|
val startVolume = player?.volume ?: normalVolume
|
||||||
|
fadeJob = scope.launch {
|
||||||
|
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
|
||||||
|
val delta = (targetVolume - startVolume) / steps
|
||||||
|
var current = startVolume
|
||||||
|
repeat(steps.toInt()) {
|
||||||
|
if (!isActive) return@launch
|
||||||
|
current = (current + delta).coerceIn(0f, 1f)
|
||||||
|
withContext(Dispatchers.Main) { player?.volume = current }
|
||||||
|
delay(FADE_STEP_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { player?.volume = targetVolume }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import de.waypointaudio.data.AudioRoutingSettings
|
||||||
|
import de.waypointaudio.data.AudioRoutingStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-weiter Singleton-Manager für Live / PTT.
|
||||||
|
*
|
||||||
|
* Steuert den [LivePttService] (startet/stoppt den Vordergrundservice) und
|
||||||
|
* verwaltet den Status (pttActive, Gerätewahl, Fehler).
|
||||||
|
*
|
||||||
|
* Audio-Priorität:
|
||||||
|
* - Wenn PTT aktiv: Begleitmusik wird pausiert (über [BackgroundMusicManager.pauseMusic]).
|
||||||
|
* - Wegpunkt-Tracks: Der [WaypointLocationService] prüft [isActive] und verschiebt Wiedergabe.
|
||||||
|
* - Beim PTT-Ende: Begleitmusik wird wiederhergestellt (über [BackgroundMusicManager.afterWaypointAudio]).
|
||||||
|
*
|
||||||
|
* Bekannte Einschränkungen:
|
||||||
|
* - Android-Audiorouting via setPreferredDevice() wird nicht von allen Geräten unterstützt.
|
||||||
|
* - Bluetooth SCO: Erfordert ggf. AudioManager.startBluetoothSco() für volles Routing.
|
||||||
|
* Dieser MVP setzt setPreferredDevice() und loggt Fehler.
|
||||||
|
* - Echo/Feedback: Bei Nutzung des eingebauten Lautsprechers ohne Headset zu erwarten.
|
||||||
|
*/
|
||||||
|
class LivePttManager private constructor(private val appContext: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LivePttManager"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: LivePttManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): LivePttManager {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: LivePttManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private val store = AudioRoutingStore(appContext)
|
||||||
|
|
||||||
|
// ─── Öffentliche States ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** true wenn PTT gerade aktiv ist. */
|
||||||
|
private val _pttActive = MutableStateFlow(false)
|
||||||
|
val pttActive: StateFlow<Boolean> = _pttActive.asStateFlow()
|
||||||
|
|
||||||
|
/** Aktuell gespeicherte Routing-Einstellungen. */
|
||||||
|
private val _routingSettings = MutableStateFlow(AudioRoutingSettings())
|
||||||
|
val routingSettings: StateFlow<AudioRoutingSettings> = _routingSettings.asStateFlow()
|
||||||
|
|
||||||
|
/** Verfügbare Eingabegeräte (aktualisiert beim Öffnen der Einstellungen). */
|
||||||
|
private val _inputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
|
||||||
|
val inputDevices: StateFlow<List<AudioDeviceItem>> = _inputDevices.asStateFlow()
|
||||||
|
|
||||||
|
/** Verfügbare Ausgabegeräte (aktualisiert beim Öffnen der Einstellungen). */
|
||||||
|
private val _outputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
|
||||||
|
val outputDevices: StateFlow<List<AudioDeviceItem>> = _outputDevices.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status-/Fehlermeldung für die UI.
|
||||||
|
* null = kein Fehler / kein Hinweis
|
||||||
|
*/
|
||||||
|
private val _statusMessage = MutableStateFlow<String?>(null)
|
||||||
|
val statusMessage: StateFlow<String?> = _statusMessage.asStateFlow()
|
||||||
|
|
||||||
|
// ─── Initialisierung ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Gespeicherte Einstellungen laden
|
||||||
|
scope.launch {
|
||||||
|
store.settings.collect { settings ->
|
||||||
|
_routingSettings.value = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PTT starten / stoppen ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet PTT:
|
||||||
|
* 1. Atmo pausieren
|
||||||
|
* 2. Foreground Service starten
|
||||||
|
* 3. Status setzen
|
||||||
|
*/
|
||||||
|
fun startPtt() {
|
||||||
|
if (_pttActive.value) {
|
||||||
|
Log.d(TAG, "PTT bereits aktiv – kein Neustart")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begleitmusik pausieren
|
||||||
|
pauseAtmo()
|
||||||
|
|
||||||
|
val settings = _routingSettings.value
|
||||||
|
val intent = Intent(appContext, LivePttService::class.java).apply {
|
||||||
|
action = LivePttService.ACTION_START_PTT
|
||||||
|
settings.selectedInputDeviceId?.let {
|
||||||
|
putExtra(LivePttService.EXTRA_INPUT_DEVICE_ID, it)
|
||||||
|
}
|
||||||
|
settings.selectedOutputDeviceId?.let {
|
||||||
|
putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
appContext.startForegroundService(intent)
|
||||||
|
_pttActive.value = true
|
||||||
|
_statusMessage.value = null
|
||||||
|
Log.i(TAG, "PTT gestartet")
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "PTT konnte nicht gestartet werden", e)
|
||||||
|
_statusMessage.value = "PTT-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
|
||||||
|
restoreAtmo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt PTT:
|
||||||
|
* 1. Foreground Service stoppen
|
||||||
|
* 2. Atmo wiederherstellen
|
||||||
|
* 3. Status setzen
|
||||||
|
*/
|
||||||
|
fun stopPtt() {
|
||||||
|
if (!_pttActive.value) return
|
||||||
|
|
||||||
|
val intent = Intent(appContext, LivePttService::class.java).apply {
|
||||||
|
action = LivePttService.ACTION_STOP_PTT
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
appContext.startService(intent)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "Fehler beim Stoppen des PTT-Service", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
_pttActive.value = false
|
||||||
|
_statusMessage.value = null
|
||||||
|
restoreAtmo()
|
||||||
|
Log.i(TAG, "PTT gestoppt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schaltet PTT ein/aus. */
|
||||||
|
fun togglePtt() {
|
||||||
|
if (_pttActive.value) stopPtt() else startPtt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Geräteverwaltung ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Lädt die verfügbaren Audiogeräte neu. */
|
||||||
|
fun refreshDevices() {
|
||||||
|
_inputDevices.value = AudioDeviceManager.getInputDevices(appContext)
|
||||||
|
_outputDevices.value = AudioDeviceManager.getOutputDevices(appContext)
|
||||||
|
Log.d(TAG, "Geräte neu geladen: ${_inputDevices.value.size} Eingabe, ${_outputDevices.value.size} Ausgabe")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert neue Routing-Einstellungen persistent. */
|
||||||
|
fun saveRoutingSettings(settings: AudioRoutingSettings) {
|
||||||
|
_routingSettings.value = settings
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
store.save(settings)
|
||||||
|
Log.i(TAG, "Routing-Einstellungen gespeichert: Input=${settings.selectedInputDeviceId}, Output=${settings.selectedOutputDeviceId}")
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Speichern der Routing-Einstellungen", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht die Statusmeldung. */
|
||||||
|
fun clearStatus() {
|
||||||
|
_statusMessage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Atmo-Integration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun pauseAtmo() {
|
||||||
|
runCatching {
|
||||||
|
val mgr = BackgroundMusicManager.getInstance(appContext)
|
||||||
|
if (mgr.player.isPlaying) {
|
||||||
|
mgr.pauseMusic()
|
||||||
|
Log.d(TAG, "Atmo pausiert für PTT")
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "Konnte Atmo nicht pausieren: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreAtmo() {
|
||||||
|
runCatching {
|
||||||
|
val mgr = BackgroundMusicManager.getInstance(appContext)
|
||||||
|
val settings = mgr.currentSettings
|
||||||
|
if (settings.enabled) {
|
||||||
|
mgr.afterWaypointAudio()
|
||||||
|
Log.d(TAG, "Atmo nach PTT wiederhergestellt")
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "Konnte Atmo nicht wiederherstellen: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ressourcen freigeben. Danach nicht mehr nutzbar. */
|
||||||
|
fun release() {
|
||||||
|
if (_pttActive.value) stopPtt()
|
||||||
|
scope.cancel()
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.AudioTrack
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import de.waypointaudio.MainActivity
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vordergrund-Dienst für Live-Mikrofon / PTT.
|
||||||
|
*
|
||||||
|
* Beim Start (ACTION_START_PTT) wird ein AudioRecord und AudioTrack-Loop gestartet,
|
||||||
|
* der PCM-Audiodaten vom Mikrofon in Echtzeit an den Lautsprecher weiterleitet.
|
||||||
|
*
|
||||||
|
* ⚠ Bekannte Einschränkungen:
|
||||||
|
* - Echo-Feedback: Wenn kein Headset verwendet wird, hört das Mikrofon den Lautsprecher
|
||||||
|
* und erzeugt ein Echo. Für Echo-Unterdrückung ist AcousticEchoCanceler (AEC) nötig,
|
||||||
|
* das je nach Hardware verfügbar ist. Diese Implementierung verwendet keine AEC.
|
||||||
|
* - Bluetooth-Latenz: Bluetooth SCO/A2DP hat systembedingte Verzögerungen (50–200 ms+).
|
||||||
|
* - USB-Geräte-IDs: Die IDs können sich nach einem Neustart ändern; bei fehlendem Gerät
|
||||||
|
* wird automatisch auf den Systemstandard zurückgefallen.
|
||||||
|
* - Android-Audiorouting: Auf manchen Geräten ignoriert das System setPreferredDevice().
|
||||||
|
* Die Ausgabe wird immer angezeigt; Fehler werden protokolliert.
|
||||||
|
*/
|
||||||
|
class LivePttService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START_PTT = "de.waypointaudio.ptt.ACTION_START"
|
||||||
|
const val ACTION_STOP_PTT = "de.waypointaudio.ptt.ACTION_STOP"
|
||||||
|
|
||||||
|
const val EXTRA_INPUT_DEVICE_ID = "input_device_id"
|
||||||
|
const val EXTRA_OUTPUT_DEVICE_ID = "output_device_id"
|
||||||
|
|
||||||
|
private const val TAG = "LivePttService"
|
||||||
|
private const val NOTIFICATION_ID = 2001
|
||||||
|
private const val CHANNEL_ID = "ptt_channel"
|
||||||
|
|
||||||
|
// Audio-Parameter für PTT (Sprachqualität, niedrige Latenz)
|
||||||
|
private const val SAMPLE_RATE = 16_000
|
||||||
|
private const val CHANNEL_IN = android.media.AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private const val CHANNEL_OUT = android.media.AudioFormat.CHANNEL_OUT_MONO
|
||||||
|
private const val ENCODING = android.media.AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var pttJob: Job? = null
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var audioTrack: AudioTrack? = null
|
||||||
|
|
||||||
|
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
Log.i(TAG, "LivePttService erstellt")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_START_PTT -> {
|
||||||
|
val inputDeviceId = intent.getIntExtra(EXTRA_INPUT_DEVICE_ID, -1).takeIf { it > 0 }
|
||||||
|
val outputDeviceId = intent.getIntExtra(EXTRA_OUTPUT_DEVICE_ID, -1).takeIf { it > 0 }
|
||||||
|
startPtt(inputDeviceId, outputDeviceId)
|
||||||
|
}
|
||||||
|
ACTION_STOP_PTT -> stopPttAndSelf()
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopPttAndSelf()
|
||||||
|
serviceScope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
Log.i(TAG, "LivePttService zerstört")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PTT-Logik ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun startPtt(inputDeviceId: Int?, outputDeviceId: Int?) {
|
||||||
|
// Sicherstellen dass kein vorheriger Loop läuft
|
||||||
|
pttJob?.cancel()
|
||||||
|
releaseAudioResources()
|
||||||
|
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification())
|
||||||
|
|
||||||
|
pttJob = serviceScope.launch {
|
||||||
|
val bufferSize = maxOf(
|
||||||
|
AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_IN, ENCODING),
|
||||||
|
AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING)
|
||||||
|
).coerceAtLeast(4096)
|
||||||
|
|
||||||
|
// AudioRecord initialisieren
|
||||||
|
val recorder = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
CHANNEL_IN,
|
||||||
|
ENCODING,
|
||||||
|
bufferSize * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bevorzugtes Eingabegerät setzen (falls konfiguriert)
|
||||||
|
if (inputDeviceId != null) {
|
||||||
|
val inputInfo = findInputDevice(inputDeviceId)
|
||||||
|
if (inputInfo != null) {
|
||||||
|
val ok = recorder.setPreferredDevice(inputInfo)
|
||||||
|
if (!ok) {
|
||||||
|
Log.w(TAG, "Eingabegerät (ID $inputDeviceId) konnte nicht gesetzt werden – Systemstandard wird verwendet")
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Eingabegerät gesetzt: ${inputInfo.productName} (ID $inputDeviceId)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Eingabegerät ID $inputDeviceId nicht gefunden – Systemstandard wird verwendet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
Log.e(TAG, "AudioRecord konnte nicht initialisiert werden")
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioTrack initialisieren
|
||||||
|
val track = AudioTrack.Builder()
|
||||||
|
.setAudioFormat(
|
||||||
|
AudioFormat.Builder()
|
||||||
|
.setSampleRate(SAMPLE_RATE)
|
||||||
|
.setEncoding(ENCODING)
|
||||||
|
.setChannelMask(CHANNEL_OUT)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setBufferSizeInBytes(bufferSize * 2)
|
||||||
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
|
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Bevorzugtes Ausgabegerät setzen (falls konfiguriert)
|
||||||
|
if (outputDeviceId != null) {
|
||||||
|
val outputInfo = findOutputDevice(outputDeviceId)
|
||||||
|
if (outputInfo != null) {
|
||||||
|
val ok = track.setPreferredDevice(outputInfo)
|
||||||
|
if (!ok) {
|
||||||
|
Log.w(TAG, "Ausgabegerät (ID $outputDeviceId) konnte nicht gesetzt werden – Systemstandard wird verwendet")
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Ausgabegerät gesetzt: ${outputInfo.productName} (ID $outputDeviceId)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Ausgabegerät ID $outputDeviceId nicht gefunden – Systemstandard wird verwendet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRecord = recorder
|
||||||
|
audioTrack = track
|
||||||
|
|
||||||
|
recorder.startRecording()
|
||||||
|
track.play()
|
||||||
|
Log.i(TAG, "PTT-Schleife gestartet (Puffergröße: $bufferSize)")
|
||||||
|
|
||||||
|
val buffer = ShortArray(bufferSize / 2)
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
val read = recorder.read(buffer, 0, buffer.size)
|
||||||
|
if (read > 0) {
|
||||||
|
track.write(buffer, 0, read)
|
||||||
|
} else if (read < 0) {
|
||||||
|
Log.w(TAG, "AudioRecord-Fehler: $read")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Log.d(TAG, "PTT-Schleife beendet")
|
||||||
|
releaseAudioResources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "PTT gestartet (Input-Gerät: $inputDeviceId, Output-Gerät: $outputDeviceId)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPttAndSelf() {
|
||||||
|
pttJob?.cancel()
|
||||||
|
pttJob = null
|
||||||
|
releaseAudioResources()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
Log.i(TAG, "PTT gestoppt")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseAudioResources() {
|
||||||
|
runCatching {
|
||||||
|
audioRecord?.apply {
|
||||||
|
if (state == AudioRecord.STATE_INITIALIZED) {
|
||||||
|
stop()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Fehler beim Freigeben von AudioRecord: ${it.message}") }
|
||||||
|
audioRecord = null
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
audioTrack?.apply {
|
||||||
|
if (state == AudioTrack.STATE_INITIALIZED) {
|
||||||
|
stop()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Fehler beim Freigeben von AudioTrack: ${it.message}") }
|
||||||
|
audioTrack = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Gerätezugriff ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun findInputDevice(deviceId: Int): AudioDeviceInfo? =
|
||||||
|
AudioDeviceManager.findInputDevice(applicationContext, deviceId)
|
||||||
|
|
||||||
|
private fun findOutputDevice(deviceId: Int): AudioDeviceInfo? =
|
||||||
|
AudioDeviceManager.findOutputDevice(applicationContext, deviceId)
|
||||||
|
|
||||||
|
// ─── Benachrichtigungen ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Live / PTT",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Live-Mikrofon (Push-to-Talk) aktiv"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopIntent = PendingIntent.getService(
|
||||||
|
this, 1,
|
||||||
|
Intent(this, LivePttService::class.java).apply { action = ACTION_STOP_PTT },
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("Live / PTT aktiv")
|
||||||
|
.setContentText("Mikrofon wird übertragen – Tippen zum Stoppen")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.addAction(android.R.drawable.ic_delete, "PTT stoppen", stopIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated audio player for manual (non-GPS) waypoint playback in the UI.
|
||||||
|
*
|
||||||
|
* Separate from [AudioPlayer] used by the location service so that manual
|
||||||
|
* playback and GPS-triggered playback do not interfere with each other.
|
||||||
|
*
|
||||||
|
* Supports two modes:
|
||||||
|
* - **Playlist mode**: play/pause/resume for the global manual player bar (prev/next work).
|
||||||
|
* - **Single mode**: triggered by a per-waypoint Play button. Plays exactly one file and
|
||||||
|
* stops on completion without advancing to any next item.
|
||||||
|
*
|
||||||
|
* Thread-safety: call only from a single thread (e.g. Main dispatcher).
|
||||||
|
*/
|
||||||
|
class ManualAudioPlayer {
|
||||||
|
|
||||||
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
|
private var currentUri: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the player is currently in single-item mode.
|
||||||
|
* In single mode, [playlistMode] is false and [singleWaypointId] holds the active waypoint ID.
|
||||||
|
*/
|
||||||
|
var isSingleMode: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** ID of the waypoint currently playing in single mode (null if not in single mode). */
|
||||||
|
var singleWaypointId: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** True while audio is actively playing (not paused, not stopped). */
|
||||||
|
val isPlaying: Boolean
|
||||||
|
get() = mediaPlayer?.isPlaying == true
|
||||||
|
|
||||||
|
/** True if a track is loaded (playing or paused). */
|
||||||
|
val hasTrack: Boolean
|
||||||
|
get() = mediaPlayer != null
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Playlist mode (existing global manual player bar)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play or resume audio in **playlist mode**.
|
||||||
|
*
|
||||||
|
* If [soundUri] matches the currently loaded (possibly paused) track,
|
||||||
|
* this call resumes from the paused position. Otherwise a new track is loaded.
|
||||||
|
*
|
||||||
|
* Clears single mode if it was active.
|
||||||
|
*
|
||||||
|
* @param context Android context
|
||||||
|
* @param soundUri content:// or file:// URI string
|
||||||
|
* @param onCompletion called when playback finishes naturally
|
||||||
|
*/
|
||||||
|
fun play(
|
||||||
|
context: Context,
|
||||||
|
soundUri: String,
|
||||||
|
onCompletion: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
if (soundUri.isBlank()) {
|
||||||
|
Log.w(TAG, "Keine Audiodatei angegeben.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving single mode → clear single state
|
||||||
|
isSingleMode = false
|
||||||
|
singleWaypointId = null
|
||||||
|
|
||||||
|
// If same track is loaded and just paused → resume
|
||||||
|
if (soundUri == currentUri && mediaPlayer != null && !isPlaying) {
|
||||||
|
runCatching {
|
||||||
|
mediaPlayer?.start()
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Fortsetzen von $soundUri", e)
|
||||||
|
releasePlayer()
|
||||||
|
loadAndPlay(context, soundUri, onCompletion)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// New or different track → fresh load
|
||||||
|
releasePlayer()
|
||||||
|
loadAndPlay(context, soundUri, onCompletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Single-item mode (per-waypoint card Play button)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a **single waypoint file** without any queue continuation.
|
||||||
|
*
|
||||||
|
* - Stops any currently active playback (playlist or single) first.
|
||||||
|
* - Sets [isSingleMode] = true and [singleWaypointId] = [waypointId].
|
||||||
|
* - On natural completion, clears both flags and calls [onCompletion].
|
||||||
|
* Does NOT advance to any next item.
|
||||||
|
* - If this waypoint is already playing in single mode, toggles pause/resume.
|
||||||
|
*
|
||||||
|
* @param context Android context
|
||||||
|
* @param waypointId Stable ID of the waypoint (for active-state tracking in UI)
|
||||||
|
* @param soundUri content:// or file:// URI string
|
||||||
|
* @param onCompletion called when playback finishes naturally (not on explicit stop/pause)
|
||||||
|
*/
|
||||||
|
fun playSingle(
|
||||||
|
context: Context,
|
||||||
|
waypointId: String,
|
||||||
|
soundUri: String,
|
||||||
|
onCompletion: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
if (soundUri.isBlank()) {
|
||||||
|
Log.w(TAG, "Keine Audiodatei für Wegpunkt $waypointId angegeben.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same waypoint already in single mode → toggle pause/resume
|
||||||
|
if (isSingleMode && singleWaypointId == waypointId && mediaPlayer != null) {
|
||||||
|
if (isPlaying) {
|
||||||
|
runCatching { mediaPlayer?.pause() }.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Pausieren (single mode) für $waypointId", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runCatching { mediaPlayer?.start() }.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Fortsetzen (single mode) für $waypointId", e)
|
||||||
|
releasePlayer()
|
||||||
|
startSingleLoad(context, waypointId, soundUri, onCompletion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different waypoint or different mode → stop current and start fresh
|
||||||
|
releasePlayer()
|
||||||
|
startSingleLoad(context, waypointId, soundUri, onCompletion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSingleLoad(
|
||||||
|
context: Context,
|
||||||
|
waypointId: String,
|
||||||
|
soundUri: String,
|
||||||
|
onCompletion: () -> Unit
|
||||||
|
) {
|
||||||
|
isSingleMode = true
|
||||||
|
singleWaypointId = waypointId
|
||||||
|
loadAndPlay(context, soundUri) {
|
||||||
|
// Natural completion: clear single state, then notify caller
|
||||||
|
isSingleMode = false
|
||||||
|
singleWaypointId = null
|
||||||
|
onCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Shared internal loading
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun loadAndPlay(
|
||||||
|
context: Context,
|
||||||
|
soundUri: String,
|
||||||
|
onCompletion: () -> Unit
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
val uri = Uri.parse(soundUri)
|
||||||
|
mediaPlayer = MediaPlayer().apply {
|
||||||
|
// Grant URI permission for content:// URIs originating from SAF
|
||||||
|
setDataSource(context, uri)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
setOnCompletionListener {
|
||||||
|
releasePlayer()
|
||||||
|
onCompletion()
|
||||||
|
}
|
||||||
|
setOnErrorListener { mp, what, extra ->
|
||||||
|
Log.e(TAG, "MediaPlayer-Fehler: what=$what extra=$extra")
|
||||||
|
mp.release()
|
||||||
|
releasePlayer()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentUri = soundUri
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
|
||||||
|
releasePlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Pause / Stop / Release
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback. Position is retained so [play] / [playSingle] can resume from here.
|
||||||
|
*/
|
||||||
|
fun pause() {
|
||||||
|
runCatching {
|
||||||
|
if (mediaPlayer?.isPlaying == true) {
|
||||||
|
mediaPlayer?.pause()
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Pausieren", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and release resources. Clears current track and single-mode state.
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
releasePlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release MediaPlayer resources. Called on ViewModel cleared.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
releasePlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releasePlayer() {
|
||||||
|
runCatching {
|
||||||
|
mediaPlayer?.let {
|
||||||
|
if (it.isPlaying) it.stop()
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaPlayer = null
|
||||||
|
currentUri = null
|
||||||
|
isSingleMode = false
|
||||||
|
singleWaypointId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ManualAudioPlayer"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import de.waypointaudio.data.GpsTrackPoint
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton-Manager für GPS-Track-Aufzeichnung im Hintergrund.
|
||||||
|
*
|
||||||
|
* Vermittelt zwischen MapScreen/ViewModel und dem TrackRecordingService.
|
||||||
|
* Hält den Aufzeichnungsstatus und die aktuell aufgezeichneten Punkte in
|
||||||
|
* StateFlows, damit die UI reaktiv aktualisiert werden kann.
|
||||||
|
*
|
||||||
|
* Nutzung:
|
||||||
|
* - TrackRecordingManager.getInstance(context) – Singleton holen
|
||||||
|
* - startRecording(context, tourName) – Dienst starten, StateFlows beginnen
|
||||||
|
* - stopRecording(context) – Dienst stoppen, letzter Track bleibt in _currentTrack
|
||||||
|
* - Vom TrackRecordingService aufgerufen: addPoint(), setRecording()
|
||||||
|
*/
|
||||||
|
object TrackRecordingManager {
|
||||||
|
|
||||||
|
// Aufzeichnungsstatus
|
||||||
|
private val _isRecording = MutableStateFlow(false)
|
||||||
|
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
|
||||||
|
|
||||||
|
// Aktuell aufgezeichnete Track-Punkte (In-Memory)
|
||||||
|
private val _currentTrack = MutableStateFlow<List<GpsTrackPoint>>(emptyList())
|
||||||
|
val currentTrack: StateFlow<List<GpsTrackPoint>> = _currentTrack.asStateFlow()
|
||||||
|
|
||||||
|
// Name der Tour, für die gerade aufgenommen wird
|
||||||
|
private val _recordingTourName = MutableStateFlow<String?>(null)
|
||||||
|
val recordingTourName: StateFlow<String?> = _recordingTourName.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet die Track-Aufzeichnung via TrackRecordingService.
|
||||||
|
* Setzt den internen State zurück und startet den Dienst.
|
||||||
|
*/
|
||||||
|
fun startRecording(context: Context, tourName: String) {
|
||||||
|
_currentTrack.value = emptyList()
|
||||||
|
_isRecording.value = true
|
||||||
|
_recordingTourName.value = tourName
|
||||||
|
|
||||||
|
val intent = Intent(context, TrackRecordingService::class.java).apply {
|
||||||
|
action = TrackRecordingService.ACTION_START
|
||||||
|
putExtra(TrackRecordingService.EXTRA_TOUR_NAME, tourName)
|
||||||
|
}
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt die Track-Aufzeichnung. Der Dienst persistiert den Track
|
||||||
|
* vor dem Stoppen. Die UI behält _currentTrack zum Anzeigen.
|
||||||
|
*/
|
||||||
|
fun stopRecording(context: Context) {
|
||||||
|
_isRecording.value = false
|
||||||
|
_recordingTourName.value = null
|
||||||
|
|
||||||
|
val intent = Intent(context, TrackRecordingService::class.java).apply {
|
||||||
|
action = TrackRecordingService.ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt einen GPS-Punkt hinzu. Wird vom TrackRecordingService aufgerufen.
|
||||||
|
*/
|
||||||
|
fun addPoint(lat: Double, lng: Double) {
|
||||||
|
val point = GpsTrackPoint(latitude = lat, longitude = lng)
|
||||||
|
_currentTrack.value = _currentTrack.value + point
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Aufzeichnungsstatus (z. B. wenn der Dienst extern gestoppt wird).
|
||||||
|
*/
|
||||||
|
fun setRecording(recording: Boolean) {
|
||||||
|
_isRecording.value = recording
|
||||||
|
if (!recording) _recordingTourName.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Track (z. B. beim Laden eines persistierten Tracks beim App-Start).
|
||||||
|
*/
|
||||||
|
fun setTrack(points: List<GpsTrackPoint>) {
|
||||||
|
_currentTrack.value = points
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht den aktuellen Track im Manager (für clearTrack aus ViewModel).
|
||||||
|
*/
|
||||||
|
fun clearTrack() {
|
||||||
|
_currentTrack.value = emptyList()
|
||||||
|
_isRecording.value = false
|
||||||
|
_recordingTourName.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.gms.location.FusedLocationProviderClient
|
||||||
|
import com.google.android.gms.location.LocationCallback
|
||||||
|
import com.google.android.gms.location.LocationRequest
|
||||||
|
import com.google.android.gms.location.LocationResult
|
||||||
|
import com.google.android.gms.location.LocationServices
|
||||||
|
import com.google.android.gms.location.Priority
|
||||||
|
import de.waypointaudio.MainActivity
|
||||||
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.repository.WaypointRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hintergrund-Dienst für GPS-Track-Aufzeichnung.
|
||||||
|
*
|
||||||
|
* Läuft als Foreground Service (Typ "location") und empfängt GPS-Positionen
|
||||||
|
* über FusedLocationProviderClient. Jeder empfangene Punkt wird an den
|
||||||
|
* TrackRecordingManager weitergegeben (StateFlow → UI) und beim Stoppen
|
||||||
|
* in den DataStore persistiert.
|
||||||
|
*
|
||||||
|
* Notification-ID und Channel sind bewusst von WaypointLocationService getrennt,
|
||||||
|
* damit beide Dienste unabhängig laufen können.
|
||||||
|
*/
|
||||||
|
class TrackRecordingService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START = "de.waypointaudio.TRACK_RECORDING_START"
|
||||||
|
const val ACTION_STOP = "de.waypointaudio.TRACK_RECORDING_STOP"
|
||||||
|
const val EXTRA_TOUR_NAME = "extra_tour_name"
|
||||||
|
|
||||||
|
private const val NOTIFICATION_ID = 2001
|
||||||
|
private const val CHANNEL_ID = "track_recording_channel"
|
||||||
|
private const val TAG = "TrackRecordingService"
|
||||||
|
|
||||||
|
// GPS-Aufzeichnungsintervall: 5 Sekunden, Mindestabstand 5 Meter
|
||||||
|
private const val LOCATION_INTERVAL_MS = 5_000L
|
||||||
|
private const val LOCATION_MIN_DISTANCE_M = 5f
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
||||||
|
private lateinit var repository: WaypointRepository
|
||||||
|
|
||||||
|
private var tourName: String = ""
|
||||||
|
|
||||||
|
private val locationCallback = object : LocationCallback() {
|
||||||
|
override fun onLocationResult(result: LocationResult) {
|
||||||
|
result.lastLocation?.let { loc ->
|
||||||
|
Log.d(TAG, "GPS-Punkt: ${loc.latitude}, ${loc.longitude}")
|
||||||
|
TrackRecordingManager.addPoint(loc.latitude, loc.longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Service-Lifecycle
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
||||||
|
repository = WaypointRepository(applicationContext)
|
||||||
|
createNotificationChannel()
|
||||||
|
Log.i(TAG, "TrackRecordingService erstellt")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_START -> {
|
||||||
|
tourName = intent.getStringExtra(EXTRA_TOUR_NAME) ?: ""
|
||||||
|
startRecording(tourName)
|
||||||
|
}
|
||||||
|
ACTION_STOP -> stopRecording()
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopRecording()
|
||||||
|
serviceScope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
Log.i(TAG, "TrackRecordingService zerstört")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Aufzeichnung starten / stoppen
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun startRecording(tour: String) {
|
||||||
|
val hasFine = ContextCompat.checkSelfPermission(
|
||||||
|
this, Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val hasCoarse = ContextCompat.checkSelfPermission(
|
||||||
|
this, Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
if (!hasFine && !hasCoarse) {
|
||||||
|
Log.e(TAG, "Kein Standort-Berechtigung – Aufzeichnung nicht möglich")
|
||||||
|
TrackRecordingManager.setRecording(false)
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = buildNotification(tour)
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
val priority = if (hasFine) Priority.PRIORITY_HIGH_ACCURACY
|
||||||
|
else Priority.PRIORITY_BALANCED_POWER_ACCURACY
|
||||||
|
|
||||||
|
val request = LocationRequest.Builder(priority, LOCATION_INTERVAL_MS)
|
||||||
|
.setMinUpdateDistanceMeters(LOCATION_MIN_DISTANCE_M)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
fusedLocationClient.requestLocationUpdates(
|
||||||
|
request,
|
||||||
|
locationCallback,
|
||||||
|
Looper.getMainLooper()
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "GPS-Updates konnten nicht gestartet werden", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Track-Aufzeichnung gestartet für Tour: $tour")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecording() {
|
||||||
|
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||||
|
|
||||||
|
// Track persistieren bevor der Dienst endet
|
||||||
|
val points = TrackRecordingManager.currentTrack.value
|
||||||
|
if (points.isNotEmpty() && tourName.isNotBlank()) {
|
||||||
|
serviceScope.launch {
|
||||||
|
runCatching {
|
||||||
|
repository.saveGpsTrack(tourName, points)
|
||||||
|
Log.i(TAG, "Track gespeichert: ${points.size} Punkte für Tour '$tourName'")
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Speichern des GPS-Tracks", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackRecordingManager.setRecording(false)
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
Log.i(TAG, "Track-Aufzeichnung gestoppt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Benachrichtigung
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.track_recording_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.track_recording_channel_description)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(tour: String): Notification {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val tourLabel = if (tour.isNotBlank()) " – $tour" else ""
|
||||||
|
val contentText = getString(R.string.track_recording_notification_text, tourLabel)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.track_recording_notification_title))
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.location.Location
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.google.android.gms.location.FusedLocationProviderClient
|
||||||
|
import com.google.android.gms.location.LocationCallback
|
||||||
|
import com.google.android.gms.location.LocationRequest
|
||||||
|
import com.google.android.gms.location.LocationResult
|
||||||
|
import com.google.android.gms.location.LocationServices
|
||||||
|
import com.google.android.gms.location.Priority
|
||||||
|
import de.waypointaudio.MainActivity
|
||||||
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.data.PlaybackMode
|
||||||
|
import de.waypointaudio.data.TourAudioSettings
|
||||||
|
import de.waypointaudio.data.TourMusicStore
|
||||||
|
import de.waypointaudio.data.Waypoint
|
||||||
|
import de.waypointaudio.repository.WaypointRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
// PTT-Manager (Singleton, lazy, null-safe)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vordergrund-Dienst fuer GPS-Ueberwachung und Wegpunkt-Erkennung.
|
||||||
|
*
|
||||||
|
* Architektur:
|
||||||
|
* - Empfaengt Standort-Updates via FusedLocationProviderClient
|
||||||
|
* - Laedt Wegpunkte aus dem Repository
|
||||||
|
* - Berechnet fuer jeden Wegpunkt die Entfernung
|
||||||
|
* - Spielt die zugehoerige Audiodatei ab, wenn der Radius betreten wird
|
||||||
|
* - Verhindert wiederholte Wiedergabe bis der Benutzer die Zone verlassen hat
|
||||||
|
* - Beruecksichtigt Abspielregeln: EVERY_ENTRY, ONCE, LIMITED_COUNT + Zeitplan
|
||||||
|
*
|
||||||
|
* Begleitmusik-Sequenz (GPS-Trigger):
|
||||||
|
* 1. beforeWaypointAudio() wird vor der Wiedergabe aufgerufen (Pause/Fade/Duck)
|
||||||
|
* 2. AudioPlayer spielt den Wegpunkt-Ton ab
|
||||||
|
* 3. Nach natuerlichem Ende ODER Fehler ruft der onCompletion-/onError-Callback
|
||||||
|
* afterWaypointAudio() auf - niemals sofort nach dem play()-Aufruf.
|
||||||
|
* Callbacks laufen auf dem MediaPlayer-Thread und werden im Main-Dispatcher
|
||||||
|
* ausgefuehrt (ExoPlayer erfordert Main-Thread).
|
||||||
|
* 4. playCount wird nur bei natuerlicher Completion inkrementiert (nicht bei Fehler,
|
||||||
|
* nicht wenn Wiedergabe gar nicht starten konnte).
|
||||||
|
*/
|
||||||
|
class WaypointLocationService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START = "de.waypointaudio.ACTION_START"
|
||||||
|
const val ACTION_STOP = "de.waypointaudio.ACTION_STOP"
|
||||||
|
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
private const val CHANNEL_ID = "waypoint_service_channel"
|
||||||
|
private const val TAG = "WaypointLocationService"
|
||||||
|
|
||||||
|
// GPS-Aktualisierungsintervall
|
||||||
|
private const val LOCATION_INTERVAL_MS = 5_000L
|
||||||
|
private const val LOCATION_MIN_INTERVAL_MS = 2_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
||||||
|
private lateinit var repository: WaypointRepository
|
||||||
|
private lateinit var musicStore: TourMusicStore
|
||||||
|
private val audioPlayer = AudioPlayer()
|
||||||
|
|
||||||
|
// Begleitmusik-Manager aus dem App-Prozess (null-safe wenn nicht initialisiert)
|
||||||
|
private val musicManager: BackgroundMusicManager?
|
||||||
|
get() = runCatching { BackgroundMusicManager.getInstance(applicationContext) }.getOrNull()
|
||||||
|
|
||||||
|
// PTT-Manager – null-safe
|
||||||
|
private val pttManager: LivePttManager?
|
||||||
|
get() = runCatching { LivePttManager.getInstance(applicationContext) }.getOrNull()
|
||||||
|
|
||||||
|
// Letzter aufgeschobener Wegpunkt (während PTT aktiv war)
|
||||||
|
private var pendingPttWaypoint: Waypoint? = null
|
||||||
|
|
||||||
|
// Set von Wegpunkt-IDs, bei denen der Nutzer gerade im Radius ist
|
||||||
|
// (verhindert mehrfaches Ausloesen)
|
||||||
|
private val insideIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
private val locationCallback = object : LocationCallback() {
|
||||||
|
override fun onLocationResult(result: LocationResult) {
|
||||||
|
result.lastLocation?.let { location ->
|
||||||
|
checkWaypoints(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service-Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
||||||
|
repository = WaypointRepository(applicationContext)
|
||||||
|
musicStore = TourMusicStore(applicationContext)
|
||||||
|
createNotificationChannel()
|
||||||
|
Log.i(TAG, "Service erstellt")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_START -> startTracking()
|
||||||
|
ACTION_STOP -> stopTracking()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopTracking()
|
||||||
|
serviceScope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
Log.i(TAG, "Service zerstoert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tracking starten / stoppen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun startTracking() {
|
||||||
|
val notification = buildNotification(getString(R.string.notification_text))
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, LOCATION_INTERVAL_MS)
|
||||||
|
.setMinUpdateIntervalMillis(LOCATION_MIN_INTERVAL_MS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
fusedLocationClient.requestLocationUpdates(
|
||||||
|
request,
|
||||||
|
locationCallback,
|
||||||
|
Looper.getMainLooper()
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Standort-Updates konnten nicht gestartet werden", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "GPS-Tracking gestartet")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopTracking() {
|
||||||
|
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||||
|
audioPlayer.stop()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
Log.i(TAG, "GPS-Tracking gestoppt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wegpunkt-Pruefung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun checkWaypoints(location: Location) {
|
||||||
|
serviceScope.launch {
|
||||||
|
val waypoints: List<Waypoint> = runCatching {
|
||||||
|
repository.waypoints.first()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val currentInside = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for (wp in waypoints) {
|
||||||
|
if (!wp.isActive) continue
|
||||||
|
|
||||||
|
val results = FloatArray(1)
|
||||||
|
Location.distanceBetween(
|
||||||
|
location.latitude, location.longitude,
|
||||||
|
wp.latitude, wp.longitude,
|
||||||
|
results
|
||||||
|
)
|
||||||
|
val distanceM = results[0]
|
||||||
|
|
||||||
|
if (distanceM <= wp.radiusMeters) {
|
||||||
|
currentInside.add(wp.id)
|
||||||
|
// Nur ausloesen, wenn zuvor ausserhalb (Eintrittsereignis)
|
||||||
|
if (!insideIds.contains(wp.id)) {
|
||||||
|
Log.i(TAG, "Wegpunkt betreten: ${wp.name} (Entfernung: ${distanceM.toInt()}m)")
|
||||||
|
onWaypointEntered(wp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDs aktualisieren (verlassene Zonen entfernen)
|
||||||
|
insideIds.retainAll(currentInside)
|
||||||
|
insideIds.addAll(currentInside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onWaypointEntered(waypoint: Waypoint) {
|
||||||
|
// Benachrichtigung aktualisieren
|
||||||
|
val msg = getString(R.string.notification_waypoint_reached, waypoint.name)
|
||||||
|
updateNotification(msg)
|
||||||
|
|
||||||
|
// Ton nur abspielen wenn Audiodatei vorhanden und Abspielregeln erfuellt
|
||||||
|
if (waypoint.soundUri.isBlank()) {
|
||||||
|
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine Audiodatei")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlaybackAllowed(waypoint)) {
|
||||||
|
Log.i(TAG, "Ton nicht abgespielt - Abspielregel oder Zeitplan verhindert Wiedergabe: ${waypoint.name}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTT-Sperre: Wenn PTT aktiv, Wegpunkt aufschoeben und spaeter abspielen
|
||||||
|
val ptt = pttManager
|
||||||
|
if (ptt != null && ptt.pttActive.value) {
|
||||||
|
Log.i(TAG, "PTT aktiv - Wegpunkt '${waypoint.name}' wird aufgeschoben")
|
||||||
|
pendingPttWaypoint = waypoint
|
||||||
|
// Sobald PTT inaktiv wird: verschobenen Wegpunkt abspielen
|
||||||
|
serviceScope.launch(Dispatchers.Main) {
|
||||||
|
ptt.pttActive.collect { active ->
|
||||||
|
if (!active) {
|
||||||
|
val pending = pendingPttWaypoint
|
||||||
|
pendingPttWaypoint = null
|
||||||
|
if (pending != null) {
|
||||||
|
Log.i(TAG, "PTT beendet - verschobenen Wegpunkt '${pending.name}' wird abgespielt")
|
||||||
|
playWaypointAudio(pending)
|
||||||
|
}
|
||||||
|
// Collector einmalig beenden (kein further collecting noetig)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playWaypointAudio(waypoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt den Wegpunkt-Ton ab (Begleitmusik-Sequenz).
|
||||||
|
* Kann direkt oder nach PTT-Ende aufgerufen werden.
|
||||||
|
*/
|
||||||
|
private fun playWaypointAudio(waypoint: Waypoint) {
|
||||||
|
// Begleitmusik-Sequenz:
|
||||||
|
// 1. beforeWaypointAudio() - Begleitmusik pausieren/ducken/faden
|
||||||
|
// 2. audioPlayer.play() - Wegpunkt-Ton starten
|
||||||
|
// 3a. onCompletion - nach naturalem Ende: afterWaypointAudio() + playCount++
|
||||||
|
// 3b. onError - bei Fehler: afterWaypointAudio() (kein playCount++)
|
||||||
|
//
|
||||||
|
// Alles laeuft im Main-Dispatcher, da ExoPlayer (Begleitmusik) Main erfordert.
|
||||||
|
// Die Callbacks von AudioPlayer kommen auf dem MediaPlayer-Thread und werden
|
||||||
|
// per serviceScope.launch(Dispatchers.Main) weitergeleitet.
|
||||||
|
|
||||||
|
serviceScope.launch(Dispatchers.Main) {
|
||||||
|
val mgr = musicManager
|
||||||
|
|
||||||
|
// Tour-Einstellungen laden
|
||||||
|
val settings: TourAudioSettings? = if (mgr != null) {
|
||||||
|
runCatching {
|
||||||
|
val tourName = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
|
||||||
|
musicStore.settingsForTour(tourName).first()
|
||||||
|
}.getOrNull()
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// 1. Begleitmusik vor Wegpunkt-Audio anpassen
|
||||||
|
if (mgr != null && settings != null && settings.enabled) {
|
||||||
|
runCatching {
|
||||||
|
mgr.currentSettings = settings
|
||||||
|
mgr.beforeWaypointAudio()
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "Begleitmusik before-Fehler: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wegpunkt-Ton abspielen
|
||||||
|
// onCompletion und onError werden auf dem MediaPlayer-Thread aufgerufen;
|
||||||
|
// wir leiten sie zurueck in den Main-Dispatcher fuer ExoPlayer-Zugriffe.
|
||||||
|
audioPlayer.play(
|
||||||
|
context = applicationContext,
|
||||||
|
soundUri = waypoint.soundUri,
|
||||||
|
onCompletion = {
|
||||||
|
// Natuerliches Ende: Begleitmusik wiederherstellen + Zaehler erhoehen
|
||||||
|
serviceScope.launch(Dispatchers.Main) {
|
||||||
|
Log.d(TAG, "Wegpunkt-Ton beendet (natural completion): ${waypoint.name}")
|
||||||
|
restoreBackgroundMusic(mgr, settings)
|
||||||
|
}
|
||||||
|
// playCount auf IO-Thread inkrementieren (Repository-Zugriff)
|
||||||
|
serviceScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
repository.incrementPlayCount(waypoint.id)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { e ->
|
||||||
|
// Fehler: Begleitmusik trotzdem wiederherstellen; kein playCount++
|
||||||
|
serviceScope.launch(Dispatchers.Main) {
|
||||||
|
Log.w(TAG, "Wegpunkt-Ton Fehler (${waypoint.name}): ${e?.message}")
|
||||||
|
restoreBackgroundMusic(mgr, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die Begleitmusik nach dem Wegpunkt-Ton wieder her.
|
||||||
|
* Muss auf dem Main-Thread aufgerufen werden (ExoPlayer-Anforderung).
|
||||||
|
*/
|
||||||
|
private fun restoreBackgroundMusic(
|
||||||
|
mgr: BackgroundMusicManager?,
|
||||||
|
settings: TourAudioSettings?
|
||||||
|
) {
|
||||||
|
if (mgr == null || settings == null || !settings.enabled) return
|
||||||
|
runCatching {
|
||||||
|
mgr.afterWaypointAudio()
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "Begleitmusik after-Fehler: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Abspielregel-Pruefung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft ob die Abspielregeln und der Zeitplan die Wiedergabe erlauben.
|
||||||
|
*
|
||||||
|
* Logik:
|
||||||
|
* 1. Modus-Pruefung:
|
||||||
|
* - EVERY_ENTRY: immer erlaubt (solange Zeitplan passt)
|
||||||
|
* - ONCE: nur wenn playCount == 0
|
||||||
|
* - LIMITED_COUNT: nur wenn playCount < maxPlayCount (null/0 kein Abspielen)
|
||||||
|
* 2. Zeitplan (nur wenn scheduleEnabled):
|
||||||
|
* - scheduleStartMillis: aktuelle Zeit muss >= Start sein
|
||||||
|
* - scheduleEndMillis: aktuelle Zeit muss <= Ende sein
|
||||||
|
* - allowedStartMinutes / allowedEndMinutes: Tagesminute muss im Fenster liegen
|
||||||
|
* (Mitternachts-uebergreifende Fenster werden unterstuetzt)
|
||||||
|
*/
|
||||||
|
private fun isPlaybackAllowed(waypoint: Waypoint): Boolean {
|
||||||
|
val nowMillis = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// --- Modus-Pruefung ---
|
||||||
|
val modeAllowed = when (waypoint.playbackMode) {
|
||||||
|
PlaybackMode.EVERY_ENTRY -> true
|
||||||
|
PlaybackMode.ONCE -> waypoint.playCount == 0
|
||||||
|
PlaybackMode.LIMITED_COUNT -> {
|
||||||
|
val max = waypoint.maxPlayCount
|
||||||
|
if (max == null || max <= 0) {
|
||||||
|
// Keine gueltige Obergrenze kein Abspielen
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
waypoint.playCount < max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modeAllowed) return false
|
||||||
|
|
||||||
|
// --- Zeitplan-Pruefung (nur wenn aktiv) ---
|
||||||
|
if (!waypoint.scheduleEnabled) return true
|
||||||
|
|
||||||
|
// Datum/Zeit-Fenster: wenn gesetzt, aktuelle Zeit muss im Bereich liegen
|
||||||
|
waypoint.scheduleStartMillis?.let { start ->
|
||||||
|
if (nowMillis < start) {
|
||||||
|
Log.d(TAG, "Zeitplan: Startzeit noch nicht erreicht fuer '${waypoint.name}'")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waypoint.scheduleEndMillis?.let { end ->
|
||||||
|
if (nowMillis > end) {
|
||||||
|
Log.d(TAG, "Zeitplan: Endzeitpunkt ueberschritten fuer '${waypoint.name}'")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taegliches Zeitfenster (Tagesminuten)
|
||||||
|
val startMin = waypoint.allowedStartMinutes
|
||||||
|
val endMin = waypoint.allowedEndMinutes
|
||||||
|
|
||||||
|
if (startMin != null || endMin != null) {
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
val currentMinuteOfDay = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
|
||||||
|
|
||||||
|
if (startMin != null && endMin != null) {
|
||||||
|
val inWindow = if (startMin <= endMin) {
|
||||||
|
// Normales Fenster, z. B. 08:00-20:00
|
||||||
|
currentMinuteOfDay in startMin..endMin
|
||||||
|
} else {
|
||||||
|
// Mitternachts-uebergreifend, z. B. 22:00-06:00
|
||||||
|
currentMinuteOfDay >= startMin || currentMinuteOfDay <= endMin
|
||||||
|
}
|
||||||
|
if (!inWindow) {
|
||||||
|
Log.d(TAG, "Zeitplan: Ausserhalb des Tages-Zeitfensters fuer '${waypoint.name}'")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if (startMin != null) {
|
||||||
|
if (currentMinuteOfDay < startMin) {
|
||||||
|
Log.d(TAG, "Zeitplan: Vor dem Tages-Start fuer '${waypoint.name}'")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if (endMin != null) {
|
||||||
|
if (currentMinuteOfDay > endMin) {
|
||||||
|
Log.d(TAG, "Zeitplan: Nach dem Tages-Ende fuer '${waypoint.name}'")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benachrichtigungen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW // lautlos, keine Vibration
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.notification_channel_description)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(contentText: String): Notification {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.notification_title))
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(text: String) {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.notify(NOTIFICATION_ID, buildNotification(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package de.waypointaudio.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.waypointaudio.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Info-Dialog: Zeigt App-Name, Beschreibung, Entwickler, E-Mail, Version
|
||||||
|
* und eine kurze Zusammenfassung der Apache License 2.0.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AboutDialog(onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// App name + description
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_app_name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Version
|
||||||
|
AboutRow(
|
||||||
|
label = stringResource(R.string.about_version_label),
|
||||||
|
value = stringResource(R.string.about_version)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Developer
|
||||||
|
AboutRow(
|
||||||
|
label = stringResource(R.string.about_developer_label),
|
||||||
|
value = stringResource(R.string.about_developer)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Email - preserved exactly as provided by developer
|
||||||
|
AboutRow(
|
||||||
|
label = stringResource(R.string.about_email_label),
|
||||||
|
value = stringResource(R.string.about_email)
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// License
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_license_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_license_name),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_license_summary),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_license_url),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Matrix contact
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_matrix_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_matrix_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Matrix ID as selectable text
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_matrix_id),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
// QR code
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.background(
|
||||||
|
color = Color.White,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
)
|
||||||
|
.padding(8.dp)
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.matrix_contact_qr),
|
||||||
|
contentDescription = stringResource(R.string.about_matrix_qr_content_description),
|
||||||
|
modifier = Modifier.size(200.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clickable button to open Matrix chat in browser or Matrix client
|
||||||
|
val openCd = stringResource(R.string.about_matrix_open_cd)
|
||||||
|
val matrixUrl = stringResource(R.string.about_matrix_url)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse(matrixUrl)
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// Fallback: explicit chooser for browser
|
||||||
|
try {
|
||||||
|
context.startActivity(Intent.createChooser(intent, null))
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
// No browser/client available - silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { contentDescription = openCd }
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.about_matrix_open_button))
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_copyright),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.about_close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutRow(label: String, value: String) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
package de.waypointaudio.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.data.MusicSourceType
|
||||||
|
import de.waypointaudio.data.PlaylistItem
|
||||||
|
import de.waypointaudio.data.TourAudioSettings
|
||||||
|
import de.waypointaudio.data.WaypointMusicBehavior
|
||||||
|
import de.waypointaudio.viewmodel.WaypointViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollbilddialog (AlertDialog mit Scroll) für Begleitmusik-Einstellungen einer Tour.
|
||||||
|
*
|
||||||
|
* Öffnet sich über das sichtbare Begleitmusik-Panel oder über das Overflow-Menü;
|
||||||
|
* zeigt alle Optionen für die aktuell gewählte Tour.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BegleitmusikDialog(
|
||||||
|
tourName: String,
|
||||||
|
viewModel: WaypointViewModel,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val initialSettings by viewModel.musicSettings.collectAsState()
|
||||||
|
val musicPlaying by viewModel.musicPlaying.collectAsState()
|
||||||
|
|
||||||
|
// Lokaler Bearbeitungszustand – erst beim Speichern in VM schreiben
|
||||||
|
var enabled by remember(initialSettings) { mutableStateOf(initialSettings.enabled) }
|
||||||
|
var sourceType by remember(initialSettings) { mutableStateOf(initialSettings.sourceType) }
|
||||||
|
var localPlaylist by remember(initialSettings) { mutableStateOf(initialSettings.localPlaylist) }
|
||||||
|
var streamUrl by remember(initialSettings) { mutableStateOf(initialSettings.streamUrl ?: "") }
|
||||||
|
var behavior by remember(initialSettings) { mutableStateOf(initialSettings.behavior) }
|
||||||
|
var fadeDurationMs by remember(initialSettings) { mutableStateOf(initialSettings.fadeDurationMs) }
|
||||||
|
var duckVolume by remember(initialSettings) { mutableStateOf(initialSettings.duckVolume) }
|
||||||
|
var shuffle by remember(initialSettings) { mutableStateOf(initialSettings.shuffle) }
|
||||||
|
var autoStartAfterWaypoint by remember(initialSettings) { mutableStateOf(initialSettings.autoStartAfterWaypoint) }
|
||||||
|
|
||||||
|
var streamUrlError by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
// Launcher für mehrere Audiodateien
|
||||||
|
val multiPickLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenMultipleDocuments()
|
||||||
|
) { uris: List<Uri> ->
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
// URI-Berechtigungen dauerhaft sichern
|
||||||
|
uris.forEach { uri ->
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newItems = uris.map { uri ->
|
||||||
|
val name = runCatching {
|
||||||
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
val nameIdx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
cursor.moveToFirst()
|
||||||
|
if (nameIdx >= 0) cursor.getString(nameIdx) else uri.lastPathSegment ?: uri.toString()
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: uri.lastPathSegment ?: uri.toString()
|
||||||
|
PlaylistItem(uri.toString(), name)
|
||||||
|
}
|
||||||
|
localPlaylist = (localPlaylist + newItems).distinctBy { it.uriString }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateAndSave(): Boolean {
|
||||||
|
if (enabled && sourceType == MusicSourceType.STREAM_URL) {
|
||||||
|
val url = streamUrl.trim()
|
||||||
|
if (url.isBlank()) {
|
||||||
|
streamUrlError = context.getString(R.string.music_stream_url_empty_error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val lower = url.lowercase()
|
||||||
|
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
|
||||||
|
streamUrlError = context.getString(R.string.music_stream_url_invalid_error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streamUrlError = ""
|
||||||
|
val settings = TourAudioSettings(
|
||||||
|
enabled = enabled,
|
||||||
|
sourceType = sourceType,
|
||||||
|
localPlaylist = localPlaylist,
|
||||||
|
streamUrl = streamUrl.trim().ifBlank { null },
|
||||||
|
behavior = behavior,
|
||||||
|
fadeDurationMs = fadeDurationMs,
|
||||||
|
duckVolume = duckVolume,
|
||||||
|
shuffle = shuffle,
|
||||||
|
autoStartAfterWaypoint = autoStartAfterWaypoint
|
||||||
|
)
|
||||||
|
viewModel.saveMusicSettings(settings)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Tour-Kontext
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_for_tour, tourName),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Aktivieren/Deaktivieren ─────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_enable),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Switch(checked = enabled, onCheckedChange = { enabled = it })
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// ─── Quelltyp ─────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_source_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = sourceType == MusicSourceType.LOCAL_PLAYLIST,
|
||||||
|
onClick = { sourceType = MusicSourceType.LOCAL_PLAYLIST },
|
||||||
|
label = { Text(stringResource(R.string.music_source_local)) },
|
||||||
|
leadingIcon = if (sourceType == MusicSourceType.LOCAL_PLAYLIST) {
|
||||||
|
{ Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = sourceType == MusicSourceType.STREAM_URL,
|
||||||
|
onClick = { sourceType = MusicSourceType.STREAM_URL },
|
||||||
|
label = { Text(stringResource(R.string.music_source_stream)) },
|
||||||
|
leadingIcon = if (sourceType == MusicSourceType.STREAM_URL) {
|
||||||
|
{ Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lokale Playlist ──────────────────────────────────────────
|
||||||
|
if (sourceType == MusicSourceType.LOCAL_PLAYLIST) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_playlist_label),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (localPlaylist.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_playlist_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
localPlaylist.forEachIndexed { idx, item ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.AudioFile,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = item.displayName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
localPlaylist = localPlaylist.toMutableList()
|
||||||
|
.also { it.removeAt(idx) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.music_playlist_remove),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
multiPickLauncher.launch(
|
||||||
|
arrayOf("audio/*", "audio/mpeg", "audio/ogg", "audio/flac", "*/*")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Add, null, Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.music_playlist_add))
|
||||||
|
}
|
||||||
|
if (localPlaylist.isNotEmpty()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { localPlaylist = emptyList() },
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.music_playlist_clear))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shuffle
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_shuffle),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Switch(checked = shuffle, onCheckedChange = { shuffle = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stream-URL ───────────────────────────────────────────────
|
||||||
|
if (sourceType == MusicSourceType.STREAM_URL) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = streamUrl,
|
||||||
|
onValueChange = { streamUrl = it; streamUrlError = "" },
|
||||||
|
label = { Text(stringResource(R.string.music_stream_url_label)) },
|
||||||
|
placeholder = { Text("https://stream.example.com/radio") },
|
||||||
|
isError = streamUrlError.isNotBlank(),
|
||||||
|
supportingText = {
|
||||||
|
if (streamUrlError.isNotBlank()) {
|
||||||
|
Text(streamUrlError, color = MaterialTheme.colorScheme.error)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_stream_url_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Radio, null) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
// Hinweis zu YouTube/SoundCloud/radio.de
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Info,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp).padding(top = 2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_stream_platform_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// ─── Verhalten beim Wegpunkt ──────────────────────────────────
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_behavior_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
BehaviorOption(
|
||||||
|
selected = behavior == WaypointMusicBehavior.PAUSE_RESUME,
|
||||||
|
title = stringResource(R.string.music_behavior_pause_resume),
|
||||||
|
description = stringResource(R.string.music_behavior_pause_resume_desc),
|
||||||
|
onClick = { behavior = WaypointMusicBehavior.PAUSE_RESUME }
|
||||||
|
)
|
||||||
|
BehaviorOption(
|
||||||
|
selected = behavior == WaypointMusicBehavior.FADE_OUT_IN,
|
||||||
|
title = stringResource(R.string.music_behavior_fade),
|
||||||
|
description = stringResource(R.string.music_behavior_fade_desc),
|
||||||
|
onClick = { behavior = WaypointMusicBehavior.FADE_OUT_IN }
|
||||||
|
)
|
||||||
|
BehaviorOption(
|
||||||
|
selected = behavior == WaypointMusicBehavior.DUCK_UNDERLAY,
|
||||||
|
title = stringResource(R.string.music_behavior_duck),
|
||||||
|
description = stringResource(R.string.music_behavior_duck_desc),
|
||||||
|
onClick = { behavior = WaypointMusicBehavior.DUCK_UNDERLAY }
|
||||||
|
)
|
||||||
|
BehaviorOption(
|
||||||
|
selected = behavior == WaypointMusicBehavior.CONTINUE_UNDERLAY,
|
||||||
|
title = stringResource(R.string.music_behavior_continue),
|
||||||
|
description = stringResource(R.string.music_behavior_continue_desc),
|
||||||
|
onClick = { behavior = WaypointMusicBehavior.CONTINUE_UNDERLAY }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Fade-Dauer (nur relevant für FADE_OUT_IN und DUCK) ───────
|
||||||
|
if (behavior == WaypointMusicBehavior.FADE_OUT_IN ||
|
||||||
|
behavior == WaypointMusicBehavior.DUCK_UNDERLAY
|
||||||
|
) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_fade_duration_label, fadeDurationMs),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = fadeDurationMs.toFloat(),
|
||||||
|
onValueChange = { fadeDurationMs = it.toLong() },
|
||||||
|
valueRange = 300f..4000f,
|
||||||
|
steps = 11,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Duck-Lautstärke ──────────────────────────────────────────
|
||||||
|
if (behavior == WaypointMusicBehavior.DUCK_UNDERLAY) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_duck_volume_label, (duckVolume * 100).toInt()),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = duckVolume,
|
||||||
|
onValueChange = { duckVolume = it },
|
||||||
|
valueRange = 0.05f..0.5f,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// ─── Autostart nach Wegpunkt ──────────────────────────────────
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_autostart_label),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_autostart_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = autoStartAfterWaypoint,
|
||||||
|
onCheckedChange = { autoStartAfterWaypoint = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// ─── Test-Steuerung ───────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.music_test_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { viewModel.toggleMusicPlayback() },
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (musicPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = stringResource(R.string.music_test_play_pause)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.stopMusicPlayback() }) {
|
||||||
|
Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.music_test_stop))
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.musicManager.player.previous() }) {
|
||||||
|
Icon(Icons.Filled.SkipPrevious, contentDescription = stringResource(R.string.manual_previous))
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.musicManager.player.next() }) {
|
||||||
|
Icon(Icons.Filled.SkipNext, contentDescription = stringResource(R.string.manual_next))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
if (validateAndSave()) onDismiss()
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
viewModel.stopMusicPlayback()
|
||||||
|
onDismiss()
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BehaviorOption(
|
||||||
|
selected: Boolean,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected, onClick = onClick)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
package de.waypointaudio.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuAnchorType
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.data.AudioRoutingSettings
|
||||||
|
import de.waypointaudio.service.AudioDeviceItem
|
||||||
|
import de.waypointaudio.service.LivePttManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Karte für Live / PTT auf dem Hauptbildschirm.
|
||||||
|
* Platzierung: zwischen Atmo-Mini-Player und Wegpunkt-Tracks-Überschrift.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - PTT starten / stoppen (Toggle-Button)
|
||||||
|
* - Statusanzeige (Bereit / Mikrofon aktiv / Berechtigung fehlt)
|
||||||
|
* - Echo-Warnung bei fehlendem Headset
|
||||||
|
* - Button zum Öffnen der Audio-Geräte-Einstellungen
|
||||||
|
* - Gerätewahl-Kurzanzeige (gewählte Geräte-Namen)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LivePttCard(
|
||||||
|
pttManager: LivePttManager,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pttActive by pttManager.pttActive.collectAsState()
|
||||||
|
val routing by pttManager.routingSettings.collectAsState()
|
||||||
|
val inputDevices by pttManager.inputDevices.collectAsState()
|
||||||
|
val statusMsg by pttManager.statusMessage.collectAsState()
|
||||||
|
|
||||||
|
var hasMicPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var showPermissionDeniedHint by remember { mutableStateOf(false) }
|
||||||
|
var showDeviceDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
hasMicPermission = granted
|
||||||
|
if (!granted) {
|
||||||
|
showPermissionDeniedHint = true
|
||||||
|
} else {
|
||||||
|
pttManager.startPtt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geräte laden wenn Karte angezeigt wird
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
pttManager.refreshDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farbanimation für PTT-Button
|
||||||
|
val pttContainerColor by animateColorAsState(
|
||||||
|
targetValue = if (pttActive)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
label = "pttColor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anzeige-Name des gewählten Eingabegeräts
|
||||||
|
val inputDeviceName = remember(routing.selectedInputDeviceId, inputDevices) {
|
||||||
|
if (routing.selectedInputDeviceId == null) null
|
||||||
|
else inputDevices.firstOrNull { it.id == routing.selectedInputDeviceId }?.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
|
||||||
|
// ── Kopfzeile: Titel + Geräte-Button ─────────────────────────────
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (pttActive) Icons.Filled.Mic else Icons.Filled.MicOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (pttActive) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ptt_card_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
// Gerätewahl-Button
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
pttManager.refreshDevices()
|
||||||
|
showDeviceDialog = true
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Settings,
|
||||||
|
contentDescription = stringResource(R.string.ptt_audio_devices_button),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// ── Status-Zeile ──────────────────────────────────────────────────
|
||||||
|
val statusText = when {
|
||||||
|
!hasMicPermission -> stringResource(R.string.ptt_status_permission_missing)
|
||||||
|
pttActive -> stringResource(R.string.ptt_status_active)
|
||||||
|
statusMsg != null -> statusMsg!!
|
||||||
|
else -> stringResource(R.string.ptt_status_ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Status-Indikator (runder Punkt)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (pttActive) MaterialTheme.colorScheme.error
|
||||||
|
else if (!hasMicPermission) MaterialTheme.colorScheme.outline
|
||||||
|
else MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = when {
|
||||||
|
pttActive -> MaterialTheme.colorScheme.error
|
||||||
|
!hasMicPermission -> MaterialTheme.colorScheme.outline
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gewähltes Gerät anzeigen (falls nicht Systemstandard)
|
||||||
|
if (inputDeviceName != null) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "🎤 $inputDeviceName",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechtigung verweigert – Hinweis
|
||||||
|
if (showPermissionDeniedHint) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ptt_permission_denied_hint),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// ── PTT-Hauptbutton ───────────────────────────────────────────────
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (!hasMicPermission) {
|
||||||
|
showPermissionDeniedHint = false
|
||||||
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
} else {
|
||||||
|
pttManager.togglePtt()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = pttContainerColor)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (pttActive) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = if (pttActive)
|
||||||
|
stringResource(R.string.ptt_stop)
|
||||||
|
else
|
||||||
|
stringResource(R.string.ptt_start),
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echo-Warnung (immer sichtbar wenn kein Headset konfiguriert)
|
||||||
|
val noHeadset = routing.selectedOutputDeviceId == null &&
|
||||||
|
routing.selectedInputDeviceId == null
|
||||||
|
if (noHeadset) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ptt_echo_warning),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audio-Geräte-Dialog ───────────────────────────────────────────────────
|
||||||
|
if (showDeviceDialog) {
|
||||||
|
AudioDeviceDialog(
|
||||||
|
pttManager = pttManager,
|
||||||
|
onDismiss = { showDeviceDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Audio-Geräte-Dialog
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AudioDeviceDialog(
|
||||||
|
pttManager: LivePttManager,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val inputDevices by pttManager.inputDevices.collectAsState()
|
||||||
|
val outputDevices by pttManager.outputDevices.collectAsState()
|
||||||
|
val routing by pttManager.routingSettings.collectAsState()
|
||||||
|
|
||||||
|
// Lokale Auswahl-States
|
||||||
|
var selectedInputId by remember(routing) { mutableStateOf(routing.selectedInputDeviceId) }
|
||||||
|
var selectedOutputId by remember(routing) { mutableStateOf(routing.selectedOutputDeviceId) }
|
||||||
|
|
||||||
|
var inputExpanded by remember { mutableStateOf(false) }
|
||||||
|
var outputExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.audio_devices_dialog_title))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// ── Refresh-Button ────────────────────────────────────────────
|
||||||
|
TextButton(
|
||||||
|
onClick = { pttManager.refreshDevices() },
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.audio_devices_refresh),
|
||||||
|
style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eingabegerät (Mikrofon) ───────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.audio_input_device_label),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inputDevices.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.audio_devices_no_input),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = inputExpanded,
|
||||||
|
onExpandedChange = { inputExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedInputId?.let { id ->
|
||||||
|
inputDevices.firstOrNull { it.id == id }?.displayName
|
||||||
|
?: stringResource(R.string.ptt_device_system_default)
|
||||||
|
} ?: stringResource(R.string.ptt_device_system_default),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.ptt_input_label)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(inputExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = inputExpanded,
|
||||||
|
onDismissRequest = { inputExpanded = false }
|
||||||
|
) {
|
||||||
|
// Systemstandard
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.ptt_device_system_default)) },
|
||||||
|
onClick = {
|
||||||
|
selectedInputId = null
|
||||||
|
inputExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
inputDevices.forEach { device ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(device.displayName) },
|
||||||
|
onClick = {
|
||||||
|
selectedInputId = device.id
|
||||||
|
inputExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ausgabegerät (Lautsprecher) ───────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.audio_output_device_label),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
if (outputDevices.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.audio_devices_no_output),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = outputExpanded,
|
||||||
|
onExpandedChange = { outputExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedOutputId?.let { id ->
|
||||||
|
outputDevices.firstOrNull { it.id == id }?.displayName
|
||||||
|
?: stringResource(R.string.ptt_device_system_default)
|
||||||
|
} ?: stringResource(R.string.ptt_device_system_default),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.ptt_output_label)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(outputExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = outputExpanded,
|
||||||
|
onDismissRequest = { outputExpanded = false }
|
||||||
|
) {
|
||||||
|
// Systemstandard
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.ptt_device_system_default)) },
|
||||||
|
onClick = {
|
||||||
|
selectedOutputId = null
|
||||||
|
outputExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
outputDevices.forEach { device ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(device.displayName) },
|
||||||
|
onClick = {
|
||||||
|
selectedOutputId = device.id
|
||||||
|
outputExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hinweis zu Geräte-IDs ─────────────────────────────────────
|
||||||
|
HorizontalDivider()
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.audio_devices_hint),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
pttManager.saveRoutingSettings(
|
||||||
|
AudioRoutingSettings(
|
||||||
|
selectedInputDeviceId = selectedInputId,
|
||||||
|
selectedOutputDeviceId = selectedOutputId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.audio_devices_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,903 @@
|
|||||||
|
package de.waypointaudio.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.AddLocation
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||||
|
import androidx.compose.material.icons.filled.MyLocation
|
||||||
|
import androidx.compose.material.icons.filled.PlayCircle
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Timeline
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
|
import com.google.android.gms.location.LocationServices
|
||||||
|
import com.google.android.gms.location.Priority
|
||||||
|
import com.google.android.gms.tasks.CancellationTokenSource
|
||||||
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.data.GpsTrackPoint
|
||||||
|
import de.waypointaudio.data.Waypoint
|
||||||
|
import de.waypointaudio.viewmodel.WaypointViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import org.osmdroid.config.Configuration
|
||||||
|
import org.osmdroid.events.MapEventsReceiver
|
||||||
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||||
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
import org.osmdroid.views.overlay.Polygon
|
||||||
|
import org.osmdroid.views.overlay.Polyline
|
||||||
|
import org.osmdroid.views.overlay.compass.CompassOverlay
|
||||||
|
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Karten-Editor mit osmdroid (OpenStreetMap).
|
||||||
|
*
|
||||||
|
* Funktionen:
|
||||||
|
* - Zeigt alle Wegpunkte der gewählten Tour mit Marker + Radius-Kreis.
|
||||||
|
* - Antippen eines Wegpunkt-Markers → Aktionsmenü (Bearbeiten / Löschen).
|
||||||
|
* - Langes Drücken auf die Karte → neuer Wegpunkt an dieser Position.
|
||||||
|
* - FAB „Mein Standort" → zentriert Karte, zeigt Puck-Marker.
|
||||||
|
* - FAB „Als Wegpunkt" → neuer Wegpunkt aus der ermittelten GPS-Position.
|
||||||
|
* - GPS-Track-Aufzeichnung: Start / Stopp, Polyline, Statistik (Punkte, Distanz, Zeit).
|
||||||
|
* - Track wird gespeichert und beim erneuten Öffnen der Tour wieder angezeigt.
|
||||||
|
* - „Wegpunkt am letzten Trackpunkt" erstellt neuen Wegpunkt am Track-Ende.
|
||||||
|
*
|
||||||
|
* Attribution: Kartendaten © OpenStreetMap-Mitwirkende (ODbL)
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MapScreen(
|
||||||
|
viewModel: WaypointViewModel,
|
||||||
|
onNavigateBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val allWaypoints by viewModel.waypoints.collectAsState()
|
||||||
|
val selectedTour by viewModel.selectedTour.collectAsState()
|
||||||
|
val tourList by viewModel.tourList.collectAsState()
|
||||||
|
val tourDefaults by viewModel.tourDefaults.collectAsState()
|
||||||
|
val currentTrack by viewModel.currentTrack.collectAsState()
|
||||||
|
val isRecording by viewModel.isRecording.collectAsState()
|
||||||
|
|
||||||
|
// Nur Wegpunkte der gewählten Tour anzeigen
|
||||||
|
val waypoints = allWaypoints.filter {
|
||||||
|
it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val primaryColor = MaterialTheme.colorScheme.primary
|
||||||
|
val errorColor = MaterialTheme.colorScheme.error
|
||||||
|
val trackColor = MaterialTheme.colorScheme.tertiary
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// osmdroid konfigurieren
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
Configuration.getInstance().apply {
|
||||||
|
userAgentValue = context.packageName
|
||||||
|
osmdroidTileCache = File(context.cacheDir, "osmdroid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapView by remember { mutableStateOf<MapView?>(null) }
|
||||||
|
var myLocationMarker by remember { mutableStateOf<Marker?>(null) }
|
||||||
|
var currentLocation by remember { mutableStateOf<Pair<Double, Double>?>(null) }
|
||||||
|
var showAddWaypointAction by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Editor-Dialog-State
|
||||||
|
var editDialogWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||||
|
var editDialogPrefill by remember { mutableStateOf<Pair<Double, Double>?>(null) }
|
||||||
|
var showEditDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Wegpunkt-Aktionsmenü (Marker antippen)
|
||||||
|
var actionMenuWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||||
|
|
||||||
|
// Single-play state for map dialog button
|
||||||
|
val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState()
|
||||||
|
|
||||||
|
// GPS-Track-Zeiterfassung
|
||||||
|
var recordingStartMs by remember { mutableLongStateOf(0L) }
|
||||||
|
var elapsedSeconds by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
// Bestätigungsdialog für das Löschen eines Wegpunkts
|
||||||
|
var deleteConfirmWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||||
|
|
||||||
|
// Bitmap für Standort-Puck (einmalig erstellt)
|
||||||
|
val puckBitmap = remember { createLocationPuckBitmap(context) }
|
||||||
|
|
||||||
|
val fusedLocationClient = remember {
|
||||||
|
LocationServices.getFusedLocationProviderClient(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben sich ändern ────────
|
||||||
|
LaunchedEffect(waypoints, primaryColor, currentTrack) {
|
||||||
|
val mv = mapView ?: return@LaunchedEffect
|
||||||
|
drawMapOverlays(
|
||||||
|
mv = mv,
|
||||||
|
waypoints = waypoints,
|
||||||
|
trackPoints = currentTrack,
|
||||||
|
primaryArgb = primaryColor.toArgb(),
|
||||||
|
errorArgb = errorColor.toArgb(),
|
||||||
|
trackArgb = trackColor.toArgb(),
|
||||||
|
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sekunden-Timer während Aufzeichnung ────────────────────────────────
|
||||||
|
LaunchedEffect(isRecording) {
|
||||||
|
if (isRecording) {
|
||||||
|
recordingStartMs = System.currentTimeMillis()
|
||||||
|
while (isRecording) {
|
||||||
|
elapsedSeconds = (System.currentTimeMillis() - recordingStartMs) / 1000L
|
||||||
|
kotlinx.coroutines.delay(1000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GPS-Track-Aufzeichnung via Foreground Service ────────────────────
|
||||||
|
// Der TrackRecordingService läuft als Foreground Service und sendet
|
||||||
|
// GPS-Punkte an TrackRecordingManager → currentTrack / isRecording StateFlows.
|
||||||
|
// Die UI beobachtet diese Flows bereits oben via viewModel.currentTrack / isRecording.
|
||||||
|
|
||||||
|
// ─── Editor-Dialog ───────────────────────────────────────────────────────
|
||||||
|
if (showEditDialog) {
|
||||||
|
WaypointEditDialog(
|
||||||
|
waypoint = editDialogWaypoint,
|
||||||
|
prefillLatLng = if (editDialogWaypoint == null) editDialogPrefill else null,
|
||||||
|
existingTours = tourList,
|
||||||
|
prefillTourName = selectedTour,
|
||||||
|
tourDefaults = tourDefaults,
|
||||||
|
onConfirm = { newWaypoint ->
|
||||||
|
viewModel.upsert(newWaypoint)
|
||||||
|
showEditDialog = false
|
||||||
|
editDialogWaypoint = null
|
||||||
|
editDialogPrefill = null
|
||||||
|
showAddWaypointAction = false
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
showEditDialog = false
|
||||||
|
editDialogWaypoint = null
|
||||||
|
editDialogPrefill = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wegpunkt-Aktionsmenü ────────────────────────────────────────────────
|
||||||
|
actionMenuWaypoint?.let { wp ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { actionMenuWaypoint = null },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) },
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt()),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
// Play single button – only shown when audio is assigned
|
||||||
|
if (wp.soundUri.isNotBlank()) {
|
||||||
|
val isThisLoaded = singlePlayingWaypointId == wp.id
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.manualPlaySingle(wp) },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
if (isThisLoaded)
|
||||||
|
stringResource(R.string.waypoint_pause_single)
|
||||||
|
else
|
||||||
|
stringResource(R.string.waypoint_play_single)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// Bearbeiten
|
||||||
|
Button(onClick = {
|
||||||
|
editDialogWaypoint = wp
|
||||||
|
editDialogPrefill = null
|
||||||
|
showEditDialog = true
|
||||||
|
actionMenuWaypoint = null
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.map_waypoint_edit))
|
||||||
|
}
|
||||||
|
// Löschen – öffnet Bestätigungsdialog
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
deleteConfirmWaypoint = wp
|
||||||
|
actionMenuWaypoint = null
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.map_waypoint_delete))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { actionMenuWaypoint = null }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bestätigungsdialog: Wegpunkt löschen ────────────────────────────────
|
||||||
|
deleteConfirmWaypoint?.let { wp ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleteConfirmWaypoint = null },
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.confirm_delete_waypoint_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
val name = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) }
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.confirm_delete_waypoint_msg, name),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.delete(wp.id)
|
||||||
|
deleteConfirmWaypoint = null
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.delete))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { deleteConfirmWaypoint = null }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(stringResource(R.string.map_view))
|
||||||
|
if (tourList.size > 1) {
|
||||||
|
Text(
|
||||||
|
text = selectedTour,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
// FAB: Als Wegpunkt übernehmen (aktueller Standort)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showAddWaypointAction && currentLocation != null,
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
editDialogPrefill = currentLocation
|
||||||
|
editDialogWaypoint = null
|
||||||
|
showEditDialog = true
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
icon = { Icon(Icons.Filled.AddLocation, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.add_waypoint_from_map)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAB: Wegpunkt am letzten Track-Punkt
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = currentTrack.isNotEmpty(),
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
val last = currentTrack.lastOrNull()
|
||||||
|
if (last != null) {
|
||||||
|
editDialogPrefill = Pair(last.latitude, last.longitude)
|
||||||
|
editDialogWaypoint = null
|
||||||
|
showEditDialog = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
icon = { Icon(Icons.Filled.Timeline, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.map_track_waypoint_at_last)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAB: GPS-Track aufnehmen / stoppen
|
||||||
|
if (isRecording) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { viewModel.stopTrackRecording() },
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
icon = { Icon(Icons.Filled.Stop, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.map_track_stop)) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { viewModel.startTrackRecording() },
|
||||||
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.FiberManualRecord, contentDescription = stringResource(R.string.map_track_start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAB: Mein Standort
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val hasFine = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val hasCoarse = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
val mv = mapView ?: return@launch
|
||||||
|
|
||||||
|
if (hasFine || hasCoarse) {
|
||||||
|
val priority = if (hasFine) Priority.PRIORITY_HIGH_ACCURACY
|
||||||
|
else Priority.PRIORITY_BALANCED_POWER_ACCURACY
|
||||||
|
|
||||||
|
val lastLoc = runCatching {
|
||||||
|
fusedLocationClient.lastLocation.await()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (lastLoc != null) {
|
||||||
|
val gp = GeoPoint(lastLoc.latitude, lastLoc.longitude)
|
||||||
|
mv.controller.animateTo(gp)
|
||||||
|
mv.controller.setZoom(16.0)
|
||||||
|
currentLocation = Pair(lastLoc.latitude, lastLoc.longitude)
|
||||||
|
showAddWaypointAction = true
|
||||||
|
myLocationMarker = updateMyLocationMarker(mv, gp, myLocationMarker, puckBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cts = CancellationTokenSource()
|
||||||
|
val freshLoc = runCatching {
|
||||||
|
fusedLocationClient.getCurrentLocation(priority, cts.token).await()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (freshLoc != null) {
|
||||||
|
val gp = GeoPoint(freshLoc.latitude, freshLoc.longitude)
|
||||||
|
mv.controller.animateTo(gp)
|
||||||
|
mv.controller.setZoom(16.0)
|
||||||
|
currentLocation = Pair(freshLoc.latitude, freshLoc.longitude)
|
||||||
|
showAddWaypointAction = true
|
||||||
|
myLocationMarker = updateMyLocationMarker(mv, gp, myLocationMarker, puckBitmap)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (waypoints.isNotEmpty()) {
|
||||||
|
val first = waypoints.first()
|
||||||
|
mv.controller.animateTo(GeoPoint(first.latitude, first.longitude))
|
||||||
|
mv.controller.setZoom(15.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MyLocation,
|
||||||
|
contentDescription = stringResource(R.string.my_location),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
// ─── OSM-Karte ───────────────────────────────────────────────────
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { ctx ->
|
||||||
|
MapView(ctx).apply {
|
||||||
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
|
setMultiTouchControls(true)
|
||||||
|
controller.setZoom(12.0)
|
||||||
|
|
||||||
|
val center = if (waypoints.isNotEmpty()) {
|
||||||
|
GeoPoint(waypoints.first().latitude, waypoints.first().longitude)
|
||||||
|
} else if (currentTrack.isNotEmpty()) {
|
||||||
|
GeoPoint(currentTrack.last().latitude, currentTrack.last().longitude)
|
||||||
|
} else {
|
||||||
|
GeoPoint(51.1657, 10.4515) // Deutschlandmitte
|
||||||
|
}
|
||||||
|
controller.setCenter(center)
|
||||||
|
|
||||||
|
// Kompass-Overlay
|
||||||
|
val compass = CompassOverlay(
|
||||||
|
ctx,
|
||||||
|
InternalCompassOrientationProvider(ctx),
|
||||||
|
this
|
||||||
|
)
|
||||||
|
compass.enableCompass()
|
||||||
|
overlays.add(compass)
|
||||||
|
|
||||||
|
// Long-Press → neuer Wegpunkt
|
||||||
|
val eventsReceiver = object : MapEventsReceiver {
|
||||||
|
override fun singleTapConfirmedHelper(p: GeoPoint?) = false
|
||||||
|
override fun longPressHelper(p: GeoPoint?): Boolean {
|
||||||
|
if (p != null) {
|
||||||
|
editDialogPrefill = Pair(p.latitude, p.longitude)
|
||||||
|
editDialogWaypoint = null
|
||||||
|
showEditDialog = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overlays.add(MapEventsOverlay(eventsReceiver))
|
||||||
|
|
||||||
|
mapView = this
|
||||||
|
drawMapOverlays(
|
||||||
|
mv = this,
|
||||||
|
waypoints = waypoints,
|
||||||
|
trackPoints = currentTrack,
|
||||||
|
primaryArgb = primaryColor.toArgb(),
|
||||||
|
errorArgb = errorColor.toArgb(),
|
||||||
|
trackArgb = trackColor.toArgb(),
|
||||||
|
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { mv ->
|
||||||
|
mapView = mv
|
||||||
|
drawMapOverlays(
|
||||||
|
mv = mv,
|
||||||
|
waypoints = waypoints,
|
||||||
|
trackPoints = currentTrack,
|
||||||
|
primaryArgb = primaryColor.toArgb(),
|
||||||
|
errorArgb = errorColor.toArgb(),
|
||||||
|
trackArgb = trackColor.toArgb(),
|
||||||
|
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Koordinaten-Infoband (GPS-Position) ─────────────────────────
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showAddWaypointAction && currentLocation != null,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
|
||||||
|
) {
|
||||||
|
currentLocation?.let { (lat, lng) ->
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f),
|
||||||
|
shadowElevation = 4.dp,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MyLocation,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"%.7f, %.7f".format(Locale.US, lat, lng),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Track-Statistik-Panel ───────────────────────────────────────
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isRecording || currentTrack.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 12.dp, bottom = 12.dp),
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = if (isRecording)
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.93f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.93f),
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||||
|
// Aufnahme-Indikator
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (isRecording) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.FiberManualRecord,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.map_track_recording),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Timeline,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.map_track_saved),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
// Statistik
|
||||||
|
val distM = trackDistanceMeters(currentTrack)
|
||||||
|
val distStr = if (distM >= 1000)
|
||||||
|
"%.1f km".format(distM / 1000.0)
|
||||||
|
else
|
||||||
|
"${distM.toInt()} m"
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.map_track_stats,
|
||||||
|
currentTrack.size,
|
||||||
|
distStr
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isRecording)
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (isRecording) {
|
||||||
|
Text(
|
||||||
|
formatElapsed(elapsedSeconds),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Track löschen (nur wenn nicht gerade aufgenommen)
|
||||||
|
if (!isRecording && currentTrack.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.clearTrack() },
|
||||||
|
modifier = Modifier.height(28.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.map_track_clear),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Editor-Hinweis (unten mittig) ──────────────────────────────
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !isRecording && currentTrack.isEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f),
|
||||||
|
shadowElevation = 2.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.map_editor_hint),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
mapView?.onDetach()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overlays zeichnen (Wegpunkte + Track-Polyline)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun drawMapOverlays(
|
||||||
|
mv: MapView,
|
||||||
|
waypoints: List<Waypoint>,
|
||||||
|
trackPoints: List<GpsTrackPoint>,
|
||||||
|
primaryArgb: Int,
|
||||||
|
errorArgb: Int,
|
||||||
|
trackArgb: Int,
|
||||||
|
onWaypointTapped: (Waypoint) -> Unit
|
||||||
|
) {
|
||||||
|
val overlays = mv.overlays
|
||||||
|
// Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen
|
||||||
|
overlays.removeAll(overlays.filterIsInstance<Polygon>().toSet())
|
||||||
|
val waypointMarkers = overlays.filterIsInstance<Marker>()
|
||||||
|
.filter { it.title != "Mein Standort" }
|
||||||
|
overlays.removeAll(waypointMarkers.toSet())
|
||||||
|
overlays.removeAll(overlays.filterIsInstance<Polyline>().toSet())
|
||||||
|
|
||||||
|
// GPS-Track zeichnen
|
||||||
|
if (trackPoints.size >= 2) {
|
||||||
|
val polyline = Polyline(mv).apply {
|
||||||
|
setPoints(trackPoints.map { GeoPoint(it.latitude, it.longitude) })
|
||||||
|
outlinePaint.color = trackArgb
|
||||||
|
outlinePaint.strokeWidth = 6f
|
||||||
|
outlinePaint.alpha = 200
|
||||||
|
}
|
||||||
|
overlays.add(polyline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wegpunkte zeichnen
|
||||||
|
for (wp in waypoints) {
|
||||||
|
val geoPoint = GeoPoint(wp.latitude, wp.longitude)
|
||||||
|
|
||||||
|
// Radius-Kreis
|
||||||
|
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
|
||||||
|
overlays.add(circle)
|
||||||
|
|
||||||
|
// Marker mit Tap-Handler
|
||||||
|
val marker = Marker(mv).apply {
|
||||||
|
position = geoPoint
|
||||||
|
title = wp.name
|
||||||
|
snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt())
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
|
alpha = if (wp.isActive) 1.0f else 0.4f
|
||||||
|
// infoWindow = null → Tap löst onMarkerClick aus
|
||||||
|
setOnMarkerClickListener { _, _ ->
|
||||||
|
onWaypointTapped(wp)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overlays.add(marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
mv.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hilfsfunktionen: Standort-Puck, Marker, Radius-Kreis, Distanz
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun createLocationPuckBitmap(context: android.content.Context): Bitmap? {
|
||||||
|
return runCatching {
|
||||||
|
val drawable = ContextCompat.getDrawable(context, R.drawable.ic_my_location_puck)
|
||||||
|
?: return@runCatching null
|
||||||
|
val wrapped = DrawableCompat.wrap(drawable).mutate()
|
||||||
|
val size = (48 * context.resources.displayMetrics.density).toInt()
|
||||||
|
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
wrapped.setBounds(0, 0, canvas.width, canvas.height)
|
||||||
|
wrapped.draw(canvas)
|
||||||
|
bitmap
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMyLocationMarker(
|
||||||
|
mapView: MapView,
|
||||||
|
position: GeoPoint,
|
||||||
|
existingMarker: Marker?,
|
||||||
|
puckBitmap: Bitmap?
|
||||||
|
): Marker {
|
||||||
|
if (existingMarker != null) {
|
||||||
|
mapView.overlays.remove(existingMarker)
|
||||||
|
}
|
||||||
|
val marker = Marker(mapView).apply {
|
||||||
|
this.position = position
|
||||||
|
title = "Mein Standort"
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
if (puckBitmap != null) {
|
||||||
|
icon = android.graphics.drawable.BitmapDrawable(
|
||||||
|
mapView.context.resources,
|
||||||
|
puckBitmap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
infoWindow = null
|
||||||
|
}
|
||||||
|
mapView.overlays.add(marker)
|
||||||
|
mapView.invalidate()
|
||||||
|
return marker
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRadiusCircle(center: GeoPoint, radiusMeters: Double, colorArgb: Int): Polygon {
|
||||||
|
val points = mutableListOf<GeoPoint>()
|
||||||
|
val steps = 64
|
||||||
|
val earthRadius = 6_371_000.0
|
||||||
|
val lat0 = Math.toRadians(center.latitude)
|
||||||
|
val lon0 = Math.toRadians(center.longitude)
|
||||||
|
val d = radiusMeters / earthRadius
|
||||||
|
for (i in 0 until steps) {
|
||||||
|
val bearing = Math.toRadians(i * 360.0 / steps)
|
||||||
|
val lat1 = Math.asin(
|
||||||
|
sin(lat0) * cos(d) + cos(lat0) * sin(d) * cos(bearing)
|
||||||
|
)
|
||||||
|
val lon1 = lon0 + atan2(
|
||||||
|
sin(bearing) * sin(d) * cos(lat0),
|
||||||
|
cos(d) - sin(lat0) * sin(lat1)
|
||||||
|
)
|
||||||
|
points.add(GeoPoint(Math.toDegrees(lat1), Math.toDegrees(lon1)))
|
||||||
|
}
|
||||||
|
points.add(points.first())
|
||||||
|
return Polygon().apply {
|
||||||
|
this.points = points
|
||||||
|
val alpha30 = (colorArgb and 0x00FFFFFF) or 0x4D000000
|
||||||
|
fillColor = alpha30
|
||||||
|
strokeColor = colorArgb
|
||||||
|
strokeWidth = 2f
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet die Gesamtdistanz eines GPS-Tracks in Metern (Haversine).
|
||||||
|
*/
|
||||||
|
private fun trackDistanceMeters(points: List<GpsTrackPoint>): Double {
|
||||||
|
if (points.size < 2) return 0.0
|
||||||
|
val R = 6_371_000.0
|
||||||
|
var total = 0.0
|
||||||
|
for (i in 1 until points.size) {
|
||||||
|
val p1 = points[i - 1]
|
||||||
|
val p2 = points[i]
|
||||||
|
val dLat = Math.toRadians(p2.latitude - p1.latitude)
|
||||||
|
val dLon = Math.toRadians(p2.longitude - p1.longitude)
|
||||||
|
val a = sin(dLat / 2).pow(2) +
|
||||||
|
cos(Math.toRadians(p1.latitude)) * cos(Math.toRadians(p2.latitude)) *
|
||||||
|
sin(dLon / 2).pow(2)
|
||||||
|
total += 2 * R * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatiert Sekunden als MM:SS oder HH:MM:SS. */
|
||||||
|
private fun formatElapsed(totalSeconds: Long): String {
|
||||||
|
val h = TimeUnit.SECONDS.toHours(totalSeconds)
|
||||||
|
val m = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
|
||||||
|
val s = totalSeconds % 60
|
||||||
|
return if (h > 0) "%d:%02d:%02d".format(h, m, s)
|
||||||
|
else "%02d:%02d".format(m, s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package de.waypointaudio.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.LocationOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildschirm, der angezeigt wird, wenn Berechtigungen fehlen.
|
||||||
|
* Leitet den Nutzer zur Systemeinstellung weiter.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PermissionRequiredScreen(onPermissionsGranted: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val permissions = buildList {
|
||||||
|
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(Manifest.permission.READ_MEDIA_AUDIO)
|
||||||
|
} else {
|
||||||
|
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinweis: Im finalen Projekt ggf. accompanist-permissions verwenden.
|
||||||
|
// Hier direkte Navigationsanweisung zur App-Einstellung.
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.LocationOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
"Berechtigungen erforderlich",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"Für GPS2Audio werden Standort- und Benachrichtigungsberechtigungen benötigt.\n\n" +
|
||||||
|
"Bitte erteilen Sie diese in den App-Einstellungen und starten Sie die App neu.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Text("Einstellungen öffnen")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedButton(onClick = onPermissionsGranted) {
|
||||||
|
Text("Erneut prüfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package de.waypointaudio.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Loop
|
||||||
|
import androidx.compose.material.icons.filled.LooksOne
|
||||||
|
import androidx.compose.material.icons.filled.Numbers
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.data.PlaybackMode
|
||||||
|
import de.waypointaudio.data.TourPlaybackDefaults
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog für Tour-weite Zähler-Aktionen.
|
||||||
|
*
|
||||||
|
* Erlaubt:
|
||||||
|
* - Abspiel-Modus für alle Wegpunkte der Tour zu setzen (bei jedem Betreten / nur einmal / begrenzt oft)
|
||||||
|
* - Zähler zurücksetzen
|
||||||
|
* - Tour-Vorgaben für neue Wegpunkte werden beim Anwenden gespeichert (Hinweis im Dialog)
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TourCounterDialog(
|
||||||
|
tourName: String,
|
||||||
|
wayPointCount: Int,
|
||||||
|
totalPlayed: Int,
|
||||||
|
currentDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
|
||||||
|
onApply: (mode: PlaybackMode, maxCount: Int?, resetCounts: Boolean) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedMode by remember { mutableStateOf(currentDefaults.playbackMode) }
|
||||||
|
var maxCountText by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (currentDefaults.maxPlayCount > 0) currentDefaults.maxPlayCount.toString() else "3"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var maxCountError by remember { mutableStateOf(false) }
|
||||||
|
var resetCounts by remember { mutableStateOf(false) }
|
||||||
|
var modeMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_counter_dialog_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = tourName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Aggregate info row
|
||||||
|
if (wayPointCount > 0) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_counter_waypoints, wayPointCount),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_counter_total_played, totalPlayed),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Reset button – standalone action
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_counter_reset_section),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onReset()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.tour_counter_reset_button))
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Mode selection section
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_counter_mode_section),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode dropdown
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = modeMenuExpanded,
|
||||||
|
onExpandedChange = { modeMenuExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = playbackModeLabel(selectedMode),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.tour_counter_mode_label)) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (selectedMode) {
|
||||||
|
PlaybackMode.EVERY_ENTRY -> Icons.Filled.Loop
|
||||||
|
PlaybackMode.ONCE -> Icons.Filled.LooksOne
|
||||||
|
PlaybackMode.LIMITED_COUNT -> Icons.Filled.Numbers
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modeMenuExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = modeMenuExpanded,
|
||||||
|
onDismissRequest = { modeMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
PlaybackMode.entries.forEach { mode ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(playbackModeLabel(mode)) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (mode) {
|
||||||
|
PlaybackMode.EVERY_ENTRY -> Icons.Filled.Loop
|
||||||
|
PlaybackMode.ONCE -> Icons.Filled.LooksOne
|
||||||
|
PlaybackMode.LIMITED_COUNT -> Icons.Filled.Numbers
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selectedMode = mode
|
||||||
|
maxCountError = false
|
||||||
|
modeMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max count field – only for LIMITED_COUNT
|
||||||
|
if (selectedMode == PlaybackMode.LIMITED_COUNT) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = maxCountText,
|
||||||
|
onValueChange = {
|
||||||
|
maxCountText = it
|
||||||
|
maxCountError = false
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.playback_max_count_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.playback_max_count_hint)) },
|
||||||
|
isError = maxCountError,
|
||||||
|
supportingText = if (maxCountError) {
|
||||||
|
{ Text(stringResource(R.string.playback_max_count_error)) }
|
||||||
|
} else null,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset counters checkbox
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = resetCounts,
|
||||||
|
onCheckedChange = { resetCounts = it }
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_counter_reset_with_apply),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint: applies to existing + saved as default for new waypoints
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_counter_apply_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (selectedMode == PlaybackMode.LIMITED_COUNT) {
|
||||||
|
val n = maxCountText.trim().toIntOrNull()
|
||||||
|
if (n == null || n < 1) {
|
||||||
|
maxCountError = true
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
onApply(selectedMode, n, resetCounts)
|
||||||
|
} else {
|
||||||
|
onApply(selectedMode, null, resetCounts)
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.tour_counter_apply_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun playbackModeLabel(mode: PlaybackMode): String = when (mode) {
|
||||||
|
PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry)
|
||||||
|
PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once)
|
||||||
|
PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited)
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
package de.waypointaudio.ui
|
||||||
|
|
||||||
|
import android.app.DatePickerDialog
|
||||||
|
import android.app.TimePickerDialog
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AudioFile
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.data.PlaybackMode
|
||||||
|
import de.waypointaudio.data.TourPlaybackDefaults
|
||||||
|
import de.waypointaudio.data.Waypoint
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
// Display formats
|
||||||
|
private val DISPLAY_DATE_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN)
|
||||||
|
private val DISPLAY_TIME_FORMAT = SimpleDateFormat("HH:mm", Locale.GERMAN)
|
||||||
|
|
||||||
|
/** Formats Long? (milliseconds) as "dd.MM.yyyy HH:mm" or empty. */
|
||||||
|
private fun Long?.toDisplayDateString(): String =
|
||||||
|
if (this == null) "" else DISPLAY_DATE_FORMAT.format(Date(this))
|
||||||
|
|
||||||
|
/** Formats Int? (day minute) as "HH:mm" or empty. */
|
||||||
|
private fun Int?.toDisplayTimeString(): String {
|
||||||
|
if (this == null) return ""
|
||||||
|
val h = this / 60
|
||||||
|
val m = this % 60
|
||||||
|
return "%02d:%02d".format(h, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog for creating and editing a waypoint.
|
||||||
|
*
|
||||||
|
* @param waypoint Existing waypoint (null = create new)
|
||||||
|
* @param prefillLatLng Pre-filled GPS coordinates (lat, lon).
|
||||||
|
* Only used for new waypoints (waypoint == null).
|
||||||
|
* @param existingTours List of existing tour names for the dropdown.
|
||||||
|
* @param prefillTourName Tour to pre-select for new waypoints (e.g. currently active tab).
|
||||||
|
* @param tourDefaults Tour-wide playback defaults – inherited by new waypoints.
|
||||||
|
* @param onConfirm Callback with the configured waypoint
|
||||||
|
* @param onDismiss Callback on cancel
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WaypointEditDialog(
|
||||||
|
waypoint: Waypoint?,
|
||||||
|
prefillLatLng: Pair<Double, Double>? = null,
|
||||||
|
existingTours: List<String> = emptyList(),
|
||||||
|
prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME,
|
||||||
|
tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
|
||||||
|
onConfirm: (Waypoint) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isNew = waypoint == null
|
||||||
|
|
||||||
|
// Coordinate pre-fill: GPS position takes priority for new waypoints
|
||||||
|
val initialLat = when {
|
||||||
|
waypoint != null -> "%.7f".format(Locale.US, waypoint.latitude)
|
||||||
|
prefillLatLng != null -> "%.7f".format(Locale.US, prefillLatLng.first)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val initialLng = when {
|
||||||
|
waypoint != null -> "%.7f".format(Locale.US, waypoint.longitude)
|
||||||
|
prefillLatLng != null -> "%.7f".format(Locale.US, prefillLatLng.second)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic field state
|
||||||
|
var name by remember { mutableStateOf(waypoint?.name ?: "") }
|
||||||
|
var latStr by remember { mutableStateOf(initialLat) }
|
||||||
|
var lngStr by remember { mutableStateOf(initialLng) }
|
||||||
|
var radiusStr by remember { mutableStateOf(waypoint?.radiusMeters?.toInt()?.toString() ?: "50") }
|
||||||
|
var soundUri by remember { mutableStateOf(waypoint?.soundUri ?: "") }
|
||||||
|
var soundName by remember { mutableStateOf(waypoint?.soundName ?: "") }
|
||||||
|
|
||||||
|
// Tour name: use existing waypoint's tour, or prefill for new, fallback to default
|
||||||
|
val initialTour = when {
|
||||||
|
waypoint != null -> waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
|
||||||
|
prefillTourName.isNotBlank() -> prefillTourName
|
||||||
|
else -> Waypoint.DEFAULT_TOUR_NAME
|
||||||
|
}
|
||||||
|
var tourName by remember { mutableStateOf(initialTour) }
|
||||||
|
var tourMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Build the selectable tour list: existing tours + current value if not yet in list
|
||||||
|
val selectableTours = remember(existingTours, tourName) {
|
||||||
|
val base = if (existingTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else existingTours
|
||||||
|
if (tourName !in base) base + tourName else base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic field validation errors
|
||||||
|
var nameError by remember { mutableStateOf(false) }
|
||||||
|
var latError by remember { mutableStateOf(false) }
|
||||||
|
var lngError by remember { mutableStateOf(false) }
|
||||||
|
var radiusError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// --- Playback rules state – inherit tour defaults for new waypoints ---
|
||||||
|
val defaultMode = if (waypoint == null) tourDefaults.playbackMode else waypoint.playbackMode
|
||||||
|
val defaultMaxCount = if (waypoint == null && tourDefaults.playbackMode == PlaybackMode.LIMITED_COUNT)
|
||||||
|
tourDefaults.maxPlayCount.toString() else waypoint?.maxPlayCount?.toString() ?: ""
|
||||||
|
var playbackMode by remember { mutableStateOf(defaultMode) }
|
||||||
|
var maxPlayCountStr by remember {
|
||||||
|
mutableStateOf(defaultMaxCount)
|
||||||
|
}
|
||||||
|
var maxPlayCountError by remember { mutableStateOf(false) }
|
||||||
|
var currentPlayCount by remember { mutableStateOf(waypoint?.playCount ?: 0) }
|
||||||
|
|
||||||
|
// Scheduler: stored as millis / minutes (nullable = not set)
|
||||||
|
var scheduleEnabled by remember { mutableStateOf(waypoint?.scheduleEnabled ?: false) }
|
||||||
|
var scheduleStartMillis by remember { mutableStateOf<Long?>(waypoint?.scheduleStartMillis) }
|
||||||
|
var scheduleEndMillis by remember { mutableStateOf<Long?>(waypoint?.scheduleEndMillis) }
|
||||||
|
var allowedStartMinutes by remember { mutableStateOf<Int?>(waypoint?.allowedStartMinutes) }
|
||||||
|
var allowedEndMinutes by remember { mutableStateOf<Int?>(waypoint?.allowedEndMinutes) }
|
||||||
|
|
||||||
|
// Schedule validation errors
|
||||||
|
var scheduleRangeError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// GPS pre-fill on change
|
||||||
|
LaunchedEffect(prefillLatLng) {
|
||||||
|
if (isNew && prefillLatLng != null) {
|
||||||
|
latStr = "%.7f".format(Locale.US, prefillLatLng.first)
|
||||||
|
lngStr = "%.7f".format(Locale.US, prefillLatLng.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio file picker
|
||||||
|
val audioPickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
if (uri != null) {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
soundUri = uri.toString()
|
||||||
|
soundName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
|
||||||
|
?: uri.toString().takeLast(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: open DatePickerDialog followed by TimePickerDialog, result via callback
|
||||||
|
fun showDateTimePicker(
|
||||||
|
initialMillis: Long?,
|
||||||
|
onResult: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
val cal = Calendar.getInstance().also { c ->
|
||||||
|
if (initialMillis != null) c.timeInMillis = initialMillis
|
||||||
|
}
|
||||||
|
DatePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, year, month, day ->
|
||||||
|
// After date is picked, open time picker
|
||||||
|
TimePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, hour, minute ->
|
||||||
|
val result = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.YEAR, year)
|
||||||
|
set(Calendar.MONTH, month)
|
||||||
|
set(Calendar.DAY_OF_MONTH, day)
|
||||||
|
set(Calendar.HOUR_OF_DAY, hour)
|
||||||
|
set(Calendar.MINUTE, minute)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
onResult(result.timeInMillis)
|
||||||
|
},
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY),
|
||||||
|
cal.get(Calendar.MINUTE),
|
||||||
|
true // 24-hour format
|
||||||
|
).show()
|
||||||
|
},
|
||||||
|
cal.get(Calendar.YEAR),
|
||||||
|
cal.get(Calendar.MONTH),
|
||||||
|
cal.get(Calendar.DAY_OF_MONTH)
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: open TimePickerDialog, result in minutes (0..1439)
|
||||||
|
fun showTimePicker(
|
||||||
|
initialMinutes: Int?,
|
||||||
|
onResult: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
val h = (initialMinutes ?: 0) / 60
|
||||||
|
val m = (initialMinutes ?: 0) % 60
|
||||||
|
TimePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, hour, minute ->
|
||||||
|
onResult(hour * 60 + minute)
|
||||||
|
},
|
||||||
|
h, m,
|
||||||
|
true // 24-hour format
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(if (isNew) R.string.add_waypoint else R.string.edit_waypoint)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// GPS pre-fill notice
|
||||||
|
if (isNew && prefillLatLng != null) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"📍 Koordinaten aus aktuellem GPS-Standort vorausgefüllt.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tour-Vorgaben-Hinweis für neue Wegpunkte
|
||||||
|
if (isNew && tourDefaults.playbackMode != de.waypointaudio.data.PlaybackMode.EVERY_ENTRY) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.waypoint_inherits_tour_defaults),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it; nameError = false },
|
||||||
|
label = { Text(stringResource(R.string.waypoint_name)) },
|
||||||
|
isError = nameError,
|
||||||
|
supportingText = if (nameError) {{ Text("Pflichtfeld") }} else null,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Tour-Zuordnung
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_section_label),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tour dropdown – selectableTours als Auswahlhilfe,
|
||||||
|
// aber freie Texteingabe ist ebenfalls möglich
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = tourMenuExpanded,
|
||||||
|
onExpandedChange = { tourMenuExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tourName,
|
||||||
|
onValueChange = { tourName = it },
|
||||||
|
label = { Text(stringResource(R.string.tour_name_label)) },
|
||||||
|
placeholder = { Text(Waypoint.DEFAULT_TOUR_NAME) },
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = tourMenuExpanded)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor()
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = tourMenuExpanded,
|
||||||
|
onDismissRequest = { tourMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
selectableTours.forEach { t ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(t) },
|
||||||
|
onClick = {
|
||||||
|
tourName = t
|
||||||
|
tourMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tour_name_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
||||||
|
// Coordinates
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = latStr,
|
||||||
|
onValueChange = { latStr = it; latError = false },
|
||||||
|
label = { Text("Lat") },
|
||||||
|
isError = latError,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lngStr,
|
||||||
|
onValueChange = { lngStr = it; lngError = false },
|
||||||
|
label = { Text("Lng") },
|
||||||
|
isError = lngError,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinates format hint
|
||||||
|
Text(
|
||||||
|
"Dezimalgrad, z. B. 48.137154 · 11.576124",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Radius
|
||||||
|
OutlinedTextField(
|
||||||
|
value = radiusStr,
|
||||||
|
onValueChange = { radiusStr = it; radiusError = false },
|
||||||
|
label = { Text(stringResource(R.string.waypoint_radius)) },
|
||||||
|
isError = radiusError,
|
||||||
|
supportingText = if (radiusError) {{ Text("Ganzzahl > 0") }} else null,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audio file selection
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { audioPickerLauncher.launch(arrayOf("audio/*")) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.AudioFile,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.choose_sound))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (soundName.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = soundName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_sound_selected),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Playback rules
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.playback_rules_section),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode selection via DropdownMenu
|
||||||
|
var modeMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val modeLabel = when (playbackMode) {
|
||||||
|
PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry)
|
||||||
|
PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once)
|
||||||
|
PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited)
|
||||||
|
}
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = modeMenuExpanded,
|
||||||
|
onExpandedChange = { modeMenuExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = modeLabel,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.playback_mode_label)) },
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = modeMenuExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor()
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = modeMenuExpanded,
|
||||||
|
onDismissRequest = { modeMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.playback_mode_every_entry)) },
|
||||||
|
onClick = {
|
||||||
|
playbackMode = PlaybackMode.EVERY_ENTRY
|
||||||
|
modeMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.playback_mode_once)) },
|
||||||
|
onClick = {
|
||||||
|
playbackMode = PlaybackMode.ONCE
|
||||||
|
modeMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.playback_mode_limited)) },
|
||||||
|
onClick = {
|
||||||
|
playbackMode = PlaybackMode.LIMITED_COUNT
|
||||||
|
modeMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max count – only visible for LIMITED_COUNT
|
||||||
|
if (playbackMode == PlaybackMode.LIMITED_COUNT) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = maxPlayCountStr,
|
||||||
|
onValueChange = { maxPlayCountStr = it; maxPlayCountError = false },
|
||||||
|
label = { Text(stringResource(R.string.playback_max_count_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.playback_max_count_hint)) },
|
||||||
|
isError = maxPlayCountError,
|
||||||
|
supportingText = if (maxPlayCountError) {
|
||||||
|
{ Text(stringResource(R.string.playback_max_count_error)) }
|
||||||
|
} else null,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter display + reset (edit mode only)
|
||||||
|
if (!isNew) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.playback_count_info, currentPlayCount),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
TextButton(onClick = { currentPlayCount = 0 }) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.playback_count_reset),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Scheduler
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_section),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_enabled_label),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = scheduleEnabled,
|
||||||
|
onCheckedChange = { scheduleEnabled = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleEnabled) {
|
||||||
|
|
||||||
|
// ---- Start date/time ----
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_start_label),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
showDateTimePicker(scheduleStartMillis) { millis ->
|
||||||
|
scheduleStartMillis = millis
|
||||||
|
scheduleRangeError = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = if (scheduleStartMillis != null)
|
||||||
|
scheduleStartMillis.toDisplayDateString()
|
||||||
|
else
|
||||||
|
stringResource(R.string.schedule_pick_start),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (scheduleStartMillis != null) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
scheduleStartMillis = null
|
||||||
|
scheduleRangeError = false
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.schedule_clear_start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- End date/time ----
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_end_label),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
showDateTimePicker(scheduleEndMillis) { millis ->
|
||||||
|
scheduleEndMillis = millis
|
||||||
|
scheduleRangeError = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = if (scheduleEndMillis != null)
|
||||||
|
scheduleEndMillis.toDisplayDateString()
|
||||||
|
else
|
||||||
|
stringResource(R.string.schedule_pick_end),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (scheduleEndMillis != null) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
scheduleEndMillis = null
|
||||||
|
scheduleRangeError = false
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.schedule_clear_end))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range validation error
|
||||||
|
if (scheduleRangeError) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_end_before_start_error),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Daily time window ----
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_daily_label),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Daily start time
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
showTimePicker(allowedStartMinutes) { minutes ->
|
||||||
|
allowedStartMinutes = minutes
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = if (allowedStartMinutes != null)
|
||||||
|
allowedStartMinutes.toDisplayTimeString()
|
||||||
|
else
|
||||||
|
stringResource(R.string.schedule_pick_daily_start),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allowedStartMinutes != null) {
|
||||||
|
TextButton(onClick = { allowedStartMinutes = null }) {
|
||||||
|
Text(stringResource(R.string.schedule_clear_window))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily end time
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
showTimePicker(allowedEndMinutes) { minutes ->
|
||||||
|
allowedEndMinutes = minutes
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = if (allowedEndMinutes != null)
|
||||||
|
allowedEndMinutes.toDisplayTimeString()
|
||||||
|
else
|
||||||
|
stringResource(R.string.schedule_pick_daily_end),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allowedEndMinutes != null) {
|
||||||
|
TextButton(onClick = { allowedEndMinutes = null }) {
|
||||||
|
Text(stringResource(R.string.schedule_clear_window))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info: daily window can cross midnight
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_daily_midnight_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
// --- Basic validation ---
|
||||||
|
nameError = name.isBlank()
|
||||||
|
val lat = latStr.replace(',', '.').toDoubleOrNull()
|
||||||
|
val lng = lngStr.replace(',', '.').toDoubleOrNull()
|
||||||
|
val radius = radiusStr.replace(',', '.').toFloatOrNull()
|
||||||
|
latError = lat == null || lat !in -90.0..90.0
|
||||||
|
lngError = lng == null || lng !in -180.0..180.0
|
||||||
|
radiusError = radius == null || radius <= 0f
|
||||||
|
|
||||||
|
// --- Tour name validation: fallback to default if blank ---
|
||||||
|
val safeTourName = tourName.trim().ifBlank { Waypoint.DEFAULT_TOUR_NAME }
|
||||||
|
|
||||||
|
// --- Playback rule validation ---
|
||||||
|
var maxCount: Int? = null
|
||||||
|
if (playbackMode == PlaybackMode.LIMITED_COUNT) {
|
||||||
|
maxCount = maxPlayCountStr.trim().toIntOrNull()
|
||||||
|
maxPlayCountError = (maxCount == null || maxCount < 1)
|
||||||
|
} else {
|
||||||
|
maxPlayCountError = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schedule validation ---
|
||||||
|
scheduleRangeError = if (scheduleEnabled) {
|
||||||
|
scheduleStartMillis != null &&
|
||||||
|
scheduleEndMillis != null &&
|
||||||
|
scheduleEndMillis!! < scheduleStartMillis!!
|
||||||
|
} else false
|
||||||
|
|
||||||
|
val hasError = nameError || latError || lngError || radiusError ||
|
||||||
|
maxPlayCountError || scheduleRangeError
|
||||||
|
|
||||||
|
if (!hasError) {
|
||||||
|
onConfirm(
|
||||||
|
(waypoint ?: Waypoint()).copy(
|
||||||
|
name = name.trim(),
|
||||||
|
latitude = lat!!,
|
||||||
|
longitude = lng!!,
|
||||||
|
radiusMeters = radius!!,
|
||||||
|
soundUri = soundUri,
|
||||||
|
soundName = soundName,
|
||||||
|
tourName = safeTourName,
|
||||||
|
// Playback rules
|
||||||
|
playbackMode = playbackMode,
|
||||||
|
maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null,
|
||||||
|
playCount = currentPlayCount,
|
||||||
|
scheduleEnabled = scheduleEnabled,
|
||||||
|
scheduleStartMillis = if (scheduleEnabled) scheduleStartMillis else null,
|
||||||
|
scheduleEndMillis = if (scheduleEnabled) scheduleEndMillis else null,
|
||||||
|
allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null,
|
||||||
|
allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes else null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
package de.waypointaudio.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// GPS2Audio – Professional Color Palette
|
||||||
|
//
|
||||||
|
// Light: off-white surface, dark-navy primary, teal/cyan accent
|
||||||
|
// Dark: deep charcoal background, teal primary, lighter cyan accent
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Shared accent tokens
|
||||||
|
private val Teal600 = Color(0xFF0B7B82) // primary light
|
||||||
|
private val Teal800 = Color(0xFF004F54) // primaryContainer light
|
||||||
|
private val Teal200 = Color(0xFF80CBC4) // primaryContainer content light
|
||||||
|
private val Cyan300 = Color(0xFF4DB6AC) // primary dark
|
||||||
|
private val Navy900 = Color(0xFF0D1B2A) // darkest navy
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(
|
||||||
|
primary = Teal600, // #0B7B82 – action & top-bar
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Color(0xFFB2DFDB), // muted teal container
|
||||||
|
onPrimaryContainer = Color(0xFF003D40),
|
||||||
|
secondary = Color(0xFF1C5461), // dark teal secondary
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = Color(0xFFCCE8EE),
|
||||||
|
onSecondaryContainer = Color(0xFF001F26),
|
||||||
|
tertiary = Color(0xFF437A22), // green for GPS-active accent
|
||||||
|
onTertiary = Color.White,
|
||||||
|
tertiaryContainer = Color(0xFFC5E6A6),
|
||||||
|
onTertiaryContainer = Color(0xFF0F2000),
|
||||||
|
background = Color(0xFFF4F6F7), // very slight blue-grey tint
|
||||||
|
onBackground = Color(0xFF0D1B2A),
|
||||||
|
surface = Color(0xFFF9FBFC),
|
||||||
|
onSurface = Color(0xFF0D1B2A),
|
||||||
|
surfaceVariant = Color(0xFFDDE8EA),
|
||||||
|
onSurfaceVariant = Color(0xFF3A4F52),
|
||||||
|
outline = Color(0xFF7AA0A5),
|
||||||
|
error = Color(0xFFA12C7B),
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = Color(0xFFFFD8ED),
|
||||||
|
onErrorContainer = Color(0xFF3A0030)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = Cyan300, // #4DB6AC – readable on dark
|
||||||
|
onPrimary = Color(0xFF00312F),
|
||||||
|
primaryContainer = Color(0xFF004F54),
|
||||||
|
onPrimaryContainer = Color(0xFFB2DFDB),
|
||||||
|
secondary = Color(0xFF80CBC4), // lighter teal
|
||||||
|
onSecondary = Color(0xFF003D40),
|
||||||
|
secondaryContainer = Color(0xFF004F54),
|
||||||
|
onSecondaryContainer = Color(0xFFB2DFDB),
|
||||||
|
tertiary = Color(0xFF6DAA45), // green accent
|
||||||
|
onTertiary = Color(0xFF1B3700),
|
||||||
|
tertiaryContainer = Color(0xFF2B5200),
|
||||||
|
onTertiaryContainer = Color(0xFFC5E6A6),
|
||||||
|
background = Color(0xFF0D1B2A), // deep navy
|
||||||
|
onBackground = Color(0xFFCFD9DC),
|
||||||
|
surface = Color(0xFF132030), // slightly lighter navy
|
||||||
|
onSurface = Color(0xFFCFD9DC),
|
||||||
|
surfaceVariant = Color(0xFF1E3040),
|
||||||
|
onSurfaceVariant = Color(0xFF8EB3BC),
|
||||||
|
outline = Color(0xFF4A7880),
|
||||||
|
error = Color(0xFFD163A7),
|
||||||
|
onError = Color(0xFF680045),
|
||||||
|
errorContainer = Color(0xFF930060),
|
||||||
|
onErrorContainer = Color(0xFFFFD8ED)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WaypointAudioTheme(
|
||||||
|
darkTheme: Boolean = false,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
GPS2Audio launcher icon
|
||||||
|
Concept: map pin silhouette (GPS) with sound-wave arcs (Audio)
|
||||||
|
Background: deep navy #0D1B2A | Foreground: teal #4DB6AC + white
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<!-- Background fill – deep navy -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#0D1B2A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
|
||||||
|
<!-- Map-pin teardrop body – teal -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#4DB6AC"
|
||||||
|
android:pathData="
|
||||||
|
M54,16
|
||||||
|
C43.5,16 35,24.5 35,35
|
||||||
|
C35,50 54,76 54,76
|
||||||
|
C54,76 73,50 73,35
|
||||||
|
C73,24.5 64.5,16 54,16Z" />
|
||||||
|
|
||||||
|
<!-- Pin inner circle – navy (cut-out effect) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#0D1B2A"
|
||||||
|
android:pathData="M54,28 a7,7 0 1 0 0.001 0Z" />
|
||||||
|
|
||||||
|
<!-- Sound-wave arc 1 – small, white, right of pin -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M68,46 Q73,52 68,58" />
|
||||||
|
|
||||||
|
<!-- Sound-wave arc 2 – medium, white -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M72,43 Q80,52 72,61" />
|
||||||
|
|
||||||
|
<!-- Sound-wave arc 3 – large, teal -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#80CBC4"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M76,40 Q87,52 76,64" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Modern location puck icon for "Mein Standort" marker on the map.
|
||||||
|
Blue/teal circular puck with white inner dot and outer accuracy ring.
|
||||||
|
Designed to look like a modern GPS location indicator, clearly distinct
|
||||||
|
from the default osmdroid waypoint markers.
|
||||||
|
|
||||||
|
ViewBox: 48×48 dp
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
|
||||||
|
<!-- Outer accuracy / pulse ring (semi-transparent teal) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#3300BCD4"
|
||||||
|
android:pathData="M24,24 m-20,0 a20,20 0 1,0 40,0 a20,20 0 1,0 -40,0" />
|
||||||
|
|
||||||
|
<!-- Mid ring border (white, subtle shadow) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#CCFFFFFF"
|
||||||
|
android:pathData="M24,24 m-14,0 a14,14 0 1,0 28,0 a14,14 0 1,0 -28,0" />
|
||||||
|
|
||||||
|
<!-- Main filled circle (Material teal / blue) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#0288D1"
|
||||||
|
android:pathData="M24,24 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0" />
|
||||||
|
|
||||||
|
<!-- Inner white dot (center) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M24,24 m-5,0 a5,5 0 1,0 10,0 a5,5 0 1,0 -10,0" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">GPS2Audio</string>
|
||||||
|
|
||||||
|
<!-- Notification (Wegpunkt-Dienst) -->
|
||||||
|
<string name="notification_channel_id">waypoint_service_channel</string>
|
||||||
|
<string name="notification_channel_name">Wegpunkt-Ortungsdienst</string>
|
||||||
|
<string name="notification_channel_description">Läuft im Hintergrund, um Wegpunkte zu erkennen</string>
|
||||||
|
<string name="notification_title">GPS2Audio aktiv</string>
|
||||||
|
<string name="notification_text">Überwache GPS-Wegpunkte…</string>
|
||||||
|
<string name="notification_waypoint_reached">Wegpunkt erreicht: %1$s</string>
|
||||||
|
|
||||||
|
<!-- Notification (Track-Aufzeichnung) -->
|
||||||
|
<string name="track_recording_channel_name">GPS-Track-Aufzeichnung</string>
|
||||||
|
<string name="track_recording_channel_description">Läuft im Hintergrund während der GPS-Track aufgezeichnet wird</string>
|
||||||
|
<string name="track_recording_notification_title">GPS2Audio – Track-Aufzeichnung</string>
|
||||||
|
<string name="track_recording_notification_text">GPS-Track wird aufgezeichnet%1$s</string>
|
||||||
|
<string name="track_recording_permission_missing">Standort-Berechtigung fehlt. Bitte erteilen Sie die Berechtigung „Genauer Standort“ in den App-Einstellungen, damit die Track-Aufzeichnung im Hintergrund funktioniert.</string>
|
||||||
|
|
||||||
|
<!-- UI strings -->
|
||||||
|
<string name="add_waypoint">Wegpunkt hinzufügen</string>
|
||||||
|
<string name="edit_waypoint">Wegpunkt bearbeiten</string>
|
||||||
|
<string name="delete_waypoint">Wegpunkt löschen</string>
|
||||||
|
<string name="no_waypoints">Noch keine Wegpunkte angelegt.</string>
|
||||||
|
<string name="waypoint_name">Name</string>
|
||||||
|
<string name="waypoint_latitude">Breitengrad (Latitude)</string>
|
||||||
|
<string name="waypoint_longitude">Längengrad (Longitude)</string>
|
||||||
|
<string name="waypoint_radius">Radius (Meter)</string>
|
||||||
|
<string name="waypoint_sound">Audiodatei</string>
|
||||||
|
<string name="choose_sound">Audiodatei auswählen</string>
|
||||||
|
<string name="no_sound_selected">Keine Audiodatei ausgewählt</string>
|
||||||
|
<string name="save">Speichern</string>
|
||||||
|
<string name="cancel">Abbrechen</string>
|
||||||
|
<string name="delete">Löschen</string>
|
||||||
|
<string name="confirm_delete_title">Wegpunkt löschen?</string>
|
||||||
|
<string name="confirm_delete_msg">Dieser Wegpunkt wird unwiderruflich gelöscht.</string>
|
||||||
|
<string name="confirm_delete_waypoint_title">Wegpunkt löschen?</string>
|
||||||
|
<string name="confirm_delete_waypoint_msg">„%1$s“ wird unwiderruflich gelöscht.</string>
|
||||||
|
<string name="service_start">Dienst starten</string>
|
||||||
|
<string name="service_stop">Dienst stoppen</string>
|
||||||
|
<string name="permission_required">Berechtigungen erforderlich</string>
|
||||||
|
<string name="permission_location_rationale">Für die Wegpunkt-Erkennung werden Standort-Berechtigungen benötigt.</string>
|
||||||
|
<string name="permission_background_rationale">Für Hintergrund-Erkennung wird die Berechtigung für Standortzugriff im Hintergrund benötigt. Bitte in den Einstellungen aktivieren.</string>
|
||||||
|
<string name="open_settings">Einstellungen öffnen</string>
|
||||||
|
<string name="use_current_location">Aktuellen Standort verwenden</string>
|
||||||
|
<string name="back">Zurück</string>
|
||||||
|
<string name="my_location">Meinen Standort anzeigen</string>
|
||||||
|
|
||||||
|
<!-- Map -->
|
||||||
|
<string name="map_view">Karte</string>
|
||||||
|
<string name="map_attribution">Kartendaten © OpenStreetMap-Mitwirkende</string>
|
||||||
|
|
||||||
|
<!-- Map: Als Wegpunkt übernehmen -->
|
||||||
|
<string name="add_waypoint_from_map">Als Wegpunkt übernehmen</string>
|
||||||
|
<string name="current_location_hint">Koordinaten aus aktuellem GPS-Standort vorausgefüllt.</string>
|
||||||
|
|
||||||
|
<!-- Map Editor -->
|
||||||
|
<string name="map_editor_hint">Marker antippen · Karte lange drücken</string>
|
||||||
|
<string name="map_waypoint_edit">Bearbeiten</string>
|
||||||
|
<string name="map_waypoint_delete">Löschen</string>
|
||||||
|
<string name="map_waypoint_unnamed">Unbenannter Wegpunkt</string>
|
||||||
|
|
||||||
|
<!-- Map: GPS-Track-Aufzeichnung -->
|
||||||
|
<string name="map_track_start">Track aufzeichnen</string>
|
||||||
|
<string name="map_track_stop">Aufzeichnung stoppen</string>
|
||||||
|
<string name="map_track_recording">Aufzeichnung läuft</string>
|
||||||
|
<string name="map_track_saved">Track gespeichert</string>
|
||||||
|
<string name="map_track_clear">Track löschen</string>
|
||||||
|
<string name="map_track_waypoint_at_last">Wegpunkt am Track-Ende</string>
|
||||||
|
<string name="map_track_stats">%1$d Punkte · %2$s</string>
|
||||||
|
|
||||||
|
<!-- Import / Export -->
|
||||||
|
<string name="export_json">Als JSON exportieren</string>
|
||||||
|
<string name="import_json">JSON importieren</string>
|
||||||
|
<string name="export_gpx">Als GPX exportieren</string>
|
||||||
|
<string name="import_gpx">GPX importieren</string>
|
||||||
|
<string name="import_export_success">Erfolgreich</string>
|
||||||
|
|
||||||
|
<!-- GPS Waypoint -->
|
||||||
|
<string name="add_waypoint_from_location">Wegpunkt aus aktuellem Standort</string>
|
||||||
|
<string name="location_loading">GPS-Position wird ermittelt…</string>
|
||||||
|
|
||||||
|
<!-- About / Info -->
|
||||||
|
<string name="menu_about">Über diese App</string>
|
||||||
|
<string name="about_title">Über diese App</string>
|
||||||
|
<string name="about_app_name">GPS2Audio</string>
|
||||||
|
<string name="about_description">Überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Wegpunkte können auch manuell in der Übersicht abgespielt werden.</string>
|
||||||
|
<string name="about_version_label">Version</string>
|
||||||
|
<string name="about_version">1.0</string>
|
||||||
|
<string name="about_developer_label">Entwickler</string>
|
||||||
|
<string name="about_developer">Marcel Mayer</string>
|
||||||
|
<string name="about_email_label">E-Mail</string>
|
||||||
|
<string name="about_email">marcel.mayer@nesohub.org</string>
|
||||||
|
<string name="about_license_label">Lizenz</string>
|
||||||
|
<string name="about_license_name">Apache License 2.0</string>
|
||||||
|
<string name="about_license_summary">Diese App ist Open Source und wird unter der Apache License 2.0 veröffentlicht. Nutzung, Veränderung, Forks und Weitergabe sind unter den Bedingungen dieser Lizenz erlaubt. Copyright- und Lizenzhinweise müssen erhalten bleiben. Die Software wird ohne Gewährleistung bereitgestellt.</string>
|
||||||
|
<string name="about_license_url">https://www.apache.org/licenses/LICENSE-2.0</string>
|
||||||
|
<string name="about_close">Schließen</string>
|
||||||
|
<string name="about_copyright">Copyright 2026 Marcel Mayer</string>
|
||||||
|
|
||||||
|
<!-- Matrix contact -->
|
||||||
|
<string name="about_matrix_label">Matrix-Kontakt</string>
|
||||||
|
<string name="about_matrix_description">Scanne den QR-Code oder tippe den Button, um mich über Matrix zu kontaktieren.</string>
|
||||||
|
<string name="about_matrix_id">@neso:nesohub.org</string>
|
||||||
|
<string name="about_matrix_url">https://matrix.to/#/@neso:nesohub.org</string>
|
||||||
|
<string name="about_matrix_open_button">Matrix-Chat öffnen</string>
|
||||||
|
<string name="about_matrix_open_cd">Matrix-Chat mit @neso:nesohub.org öffnen</string>
|
||||||
|
<string name="about_matrix_qr_content_description">QR-Code für Matrix-Kontakt @neso:nesohub.org</string>
|
||||||
|
|
||||||
|
<!-- Abspielregeln -->
|
||||||
|
<string name="playback_rules_section">Abspielregeln</string>
|
||||||
|
<string name="playback_mode_label">Abspiel-Modus</string>
|
||||||
|
<string name="playback_mode_every_entry">Bei jedem erneuten Betreten</string>
|
||||||
|
<string name="playback_mode_once">Nur einmal</string>
|
||||||
|
<string name="playback_mode_limited">Begrenzt oft</string>
|
||||||
|
<string name="playback_max_count_label">Maximale Abspielanzahl</string>
|
||||||
|
<string name="playback_max_count_hint">Anzahl (z. B. 3)</string>
|
||||||
|
<string name="playback_max_count_error">Muss eine Ganzzahl ≥ 1 sein</string>
|
||||||
|
<string name="playback_count_info">Bisher abgespielt: %1$d</string>
|
||||||
|
<string name="playback_count_reset">Zähler zurücksetzen</string>
|
||||||
|
<string name="schedule_section">Zeitplan</string>
|
||||||
|
<string name="schedule_enabled_label">Zeitplan aktivieren</string>
|
||||||
|
<string name="schedule_start_label">Startdatum und -uhrzeit</string>
|
||||||
|
<string name="schedule_end_label">Enddatum und -uhrzeit</string>
|
||||||
|
<string name="schedule_pick_start">Start auswählen …</string>
|
||||||
|
<string name="schedule_pick_end">Ende auswählen …</string>
|
||||||
|
<string name="schedule_clear_start">Start entfernen</string>
|
||||||
|
<string name="schedule_clear_end">Ende entfernen</string>
|
||||||
|
<string name="schedule_daily_label">Tägliches Zeitfenster</string>
|
||||||
|
<string name="schedule_daily_start_label">Täglicher Beginn</string>
|
||||||
|
<string name="schedule_daily_end_label">Tägliches Ende</string>
|
||||||
|
<string name="schedule_pick_daily_start">Beginn auswählen …</string>
|
||||||
|
<string name="schedule_pick_daily_end">Ende auswählen …</string>
|
||||||
|
<string name="schedule_clear_window">Zeitfenster entfernen</string>
|
||||||
|
<string name="schedule_daily_midnight_hint">Tipp: Ein Zeitfenster darf Mitternacht überschreiten (z. B. 22:00 – 06:00).</string>
|
||||||
|
<string name="schedule_end_before_start_error">Ende muss nach dem Start liegen</string>
|
||||||
|
|
||||||
|
<!-- Playback rule summary (list card) -->
|
||||||
|
<string name="rule_summary_every_entry">Bei jedem Betreten</string>
|
||||||
|
<string name="rule_summary_once">Nur einmal</string>
|
||||||
|
<string name="rule_summary_limited">Max. %1$d×</string>
|
||||||
|
<string name="rule_summary_played">%1$d gespielt</string>
|
||||||
|
<string name="rule_summary_schedule_active">Zeitplan aktiv</string>
|
||||||
|
|
||||||
|
<!-- Touren / Routen -->
|
||||||
|
<string name="tour_section_label">Tour</string>
|
||||||
|
<string name="tour_name_label">Tourname</string>
|
||||||
|
<string name="tour_name_hint">Vorhandene Tour wählen oder neuen Namen eingeben</string>
|
||||||
|
<string name="tour_name_empty_error">Tourname darf nicht leer sein</string>
|
||||||
|
<string name="tour_name_duplicate_error">Eine Tour mit diesem Namen existiert bereits</string>
|
||||||
|
<string name="tour_new">Neue Tour</string>
|
||||||
|
<string name="tour_rename">Tour umbenennen</string>
|
||||||
|
<string name="tour_delete">Tour löschen</string>
|
||||||
|
<string name="tour_delete_title">Tour löschen?</string>
|
||||||
|
<string name="tour_delete_msg">Die Tour „%1$s" wird gelöscht. Alle Wegpunkte dieser Tour werden in die Standardtour verschoben.</string>
|
||||||
|
<string name="tour_delete_standard_hint">Standardtour kann nicht gelöscht werden</string>
|
||||||
|
<string name="tour_no_waypoints">Diese Tour enthält noch keine Wegpunkte.\nTippe auf +, um einen hinzuzufügen.</string>
|
||||||
|
<string name="tour_default_name">Standard</string>
|
||||||
|
|
||||||
|
<!-- Tour-Vorgaben für neue Wegpunkte -->
|
||||||
|
<string name="waypoint_inherits_tour_defaults">Abspielregel aus Tour-Vorgabe übernommen. Kann hier überschrieben werden.</string>
|
||||||
|
|
||||||
|
<!-- Manuelle Wiedergabe -->
|
||||||
|
<string name="manual_player_label">Manuelle Wiedergabe</string>
|
||||||
|
<string name="manual_play">Abspielen</string>
|
||||||
|
<string name="manual_pause">Pause</string>
|
||||||
|
<string name="manual_previous">Vorheriger Wegpunkt</string>
|
||||||
|
<string name="manual_next">Nächster Wegpunkt</string>
|
||||||
|
<string name="manual_no_audio">Kein Wegpunkt mit Audio verfügbar</string>
|
||||||
|
<string name="manual_now_playing">Manuell: %1$s</string>
|
||||||
|
<string name="manual_play_pause">Wiedergabe / Pause</string>
|
||||||
|
<string name="manual_prev_next_disabled_single">Einzel-Wiedergabe aktiv</string>
|
||||||
|
|
||||||
|
<!-- Einzel-Wiedergabe je Wegpunkt (Karten-Schaltfläche) -->
|
||||||
|
<string name="waypoint_play_single">Diesen Wegpunkt abspielen</string>
|
||||||
|
<string name="waypoint_pause_single">Diesen Wegpunkt pausieren</string>
|
||||||
|
<string name="waypoint_no_audio_hint">Keine Audiodatei zugewiesen</string>
|
||||||
|
<string name="waypoint_single_now_playing">Einzelwiedergabe: %1$s</string>
|
||||||
|
<string name="waypoint_single_mode_label">Einzelwiedergabe (kein Auto-Weiter)</string>
|
||||||
|
|
||||||
|
<!-- Begleitmusik -->
|
||||||
|
<string name="menu_begleitmusik">Begleitmusik</string>
|
||||||
|
<string name="music_title">Begleitmusik</string>
|
||||||
|
<string name="music_for_tour">Tour: %1$s</string>
|
||||||
|
<string name="music_enable">Begleitmusik aktivieren</string>
|
||||||
|
|
||||||
|
<!-- Quelle -->
|
||||||
|
<string name="music_source_label">Musikquelle</string>
|
||||||
|
<string name="music_source_local">Lokale Playlist</string>
|
||||||
|
<string name="music_source_stream">Stream-URL</string>
|
||||||
|
|
||||||
|
<!-- Lokale Playlist -->
|
||||||
|
<string name="music_playlist_label">Playlist</string>
|
||||||
|
<string name="music_playlist_empty">Noch keine Audiodateien ausgewählt.</string>
|
||||||
|
<string name="music_playlist_add">Dateien hinzufügen</string>
|
||||||
|
<string name="music_playlist_clear">Playlist leeren</string>
|
||||||
|
<string name="music_playlist_remove">Entfernen</string>
|
||||||
|
<string name="music_shuffle">Zufällige Reihenfolge</string>
|
||||||
|
|
||||||
|
<!-- Stream-URL -->
|
||||||
|
<string name="music_stream_url_label">Stream-URL</string>
|
||||||
|
<string name="music_stream_url_hint">Direkte http/https-Audio-URL erforderlich (z. B. Internetradio)</string>
|
||||||
|
<string name="music_stream_url_empty_error">Bitte eine URL eingeben</string>
|
||||||
|
<string name="music_stream_url_invalid_error">URL muss mit http:// oder https:// beginnen</string>
|
||||||
|
<string name="music_stream_platform_hint">Hinweis: YouTube, SoundCloud und radio.de geben keine direkt abspielbaren Audio-URLs aus. Bitte eine direkte Stream-URL (z. B. von Internetradio-Anbietern) eingeben. Seiten-Links funktionieren nicht.</string>
|
||||||
|
|
||||||
|
<!-- Verhalten beim Wegpunkt -->
|
||||||
|
<string name="music_behavior_label">Verhalten bei Wegpunkt-Audio</string>
|
||||||
|
<string name="music_behavior_pause_resume">Pausieren und fortsetzen</string>
|
||||||
|
<string name="music_behavior_pause_resume_desc">Musik pausiert während des Wegpunkt-Tons und setzt danach fort.</string>
|
||||||
|
<string name="music_behavior_fade">Fade-out / Fade-in</string>
|
||||||
|
<string name="music_behavior_fade_desc">Musik wird sanft ausgeblendet, Wegpunkt spielt, danach Einblenden.</string>
|
||||||
|
<string name="music_behavior_duck">Leise unterlegen (Duck)</string>
|
||||||
|
<string name="music_behavior_duck_desc">Musik läuft leiser weiter während der Wegpunkt-Ton spielt.</string>
|
||||||
|
<string name="music_behavior_continue">Normal unterlegen</string>
|
||||||
|
<string name="music_behavior_continue_desc">Musik läuft auf normaler Lautstärke parallel zum Wegpunkt-Ton.</string>
|
||||||
|
|
||||||
|
<!-- Fade und Duck Parameter -->
|
||||||
|
<string name="music_fade_duration_label">Fade-Dauer: %1$d ms</string>
|
||||||
|
<string name="music_duck_volume_label">Lautstärke während Wegpunkt: %1$d %%</string>
|
||||||
|
|
||||||
|
<!-- Autostart nach Wegpunkt -->
|
||||||
|
<string name="music_autostart_label">Nach Waypoint automatisch Begleitmusik starten</string>
|
||||||
|
<string name="music_autostart_desc">Startet die Begleitmusik nach dem Wegpunkt-Ton, auch wenn sie zuvor nicht spielte.</string>
|
||||||
|
|
||||||
|
<!-- Test-Steuerung -->
|
||||||
|
<string name="music_test_label">Vorschau / Test</string>
|
||||||
|
<string name="music_test_play_pause">Abspielen / Pause</string>
|
||||||
|
<string name="music_test_stop">Stopp</string>
|
||||||
|
|
||||||
|
<!-- Begleitmusik-Karte (Hauptansicht) – auf der Hauptseite als "Atmo" angezeigt -->
|
||||||
|
<string name="music_card_title">Atmo</string>
|
||||||
|
<string name="waypoint_tracks_heading">Wegpunkt-Tracks</string>
|
||||||
|
<string name="music_card_disabled">Nicht aktiviert – tippe auf Konfigurieren</string>
|
||||||
|
<string name="music_card_no_source">Keine Quelle konfiguriert</string>
|
||||||
|
<string name="music_card_local_playlist">Lokale Playlist: %1$d Titel</string>
|
||||||
|
<string name="music_card_stream">Stream: %1$s</string>
|
||||||
|
<string name="music_card_configure">Konfigurieren</string>
|
||||||
|
<string name="music_card_autostart_active">Autostart nach Waypoint aktiv</string>
|
||||||
|
<string name="music_card_behavior_pause_resume">Pausieren & fortsetzen</string>
|
||||||
|
<string name="music_card_behavior_fade">Fade-out / Fade-in</string>
|
||||||
|
<string name="music_card_behavior_duck">Leise unterlegen</string>
|
||||||
|
<string name="music_card_behavior_continue">Parallel weiterspielen</string>
|
||||||
|
|
||||||
|
<!-- Mini-Player (Begleitmusik-Karte) -->
|
||||||
|
<string name="music_player_live">Live</string>
|
||||||
|
<string name="music_player_next_label">Als nächstes:</string>
|
||||||
|
<string name="music_player_previous">Vorheriger Titel</string>
|
||||||
|
<string name="music_player_next">Nächster Titel</string>
|
||||||
|
<string name="music_player_stop">Begleitmusik stoppen</string>
|
||||||
|
<string name="music_player_play_pause">Begleitmusik abspielen / pausieren</string>
|
||||||
|
<string name="music_player_track_of">%1$d / %2$d</string>
|
||||||
|
<string name="music_player_no_title">Unbekannter Titel</string>
|
||||||
|
<string name="music_player_stream_label">Stream</string>
|
||||||
|
<string name="music_player_idle">Bereit – tippe ▶ zum Starten</string>
|
||||||
|
|
||||||
|
<!-- Tour-Zähler-Karte (Hauptansicht) -->
|
||||||
|
<string name="tour_counter_card_title">Tour-Zähler</string>
|
||||||
|
<string name="tour_counter_card_configure">Einstellen</string>
|
||||||
|
<string name="tour_counter_card_played">%1$d× abgespielt</string>
|
||||||
|
<string name="tour_counter_card_limited">%1$d begrenzt</string>
|
||||||
|
<string name="tour_counter_card_once">%1$d einmalig</string>
|
||||||
|
<string name="tour_counter_card_every_entry">%1$d jedes Mal</string>
|
||||||
|
<string name="tour_counter_card_all_every_entry">Alle Wegpunkte: bei jedem Betreten</string>
|
||||||
|
|
||||||
|
<!-- Live / PTT -->
|
||||||
|
<string name="ptt_card_title">Live / PTT</string>
|
||||||
|
<string name="ptt_start">PTT starten</string>
|
||||||
|
<string name="ptt_stop">PTT stoppen</string>
|
||||||
|
<string name="ptt_status_ready">Bereit</string>
|
||||||
|
<string name="ptt_status_active">Mikrofon aktiv</string>
|
||||||
|
<string name="ptt_status_permission_missing">Mikrofon-Berechtigung fehlt</string>
|
||||||
|
<string name="ptt_status_no_input_device">Kein Eingabegerät gewählt (Systemstandard)</string>
|
||||||
|
<string name="ptt_status_blocked_by_waypoint">Wegpunkt-Audio läuft</string>
|
||||||
|
<string name="ptt_permission_rationale">Für die Live/PTT-Funktion wird die Mikrofon-Berechtigung benötigt.</string>
|
||||||
|
<string name="ptt_permission_denied_hint">Berechtigung verweigert. Bitte in den App-Einstellungen aktivieren.</string>
|
||||||
|
<string name="ptt_blocked_by_ptt">PTT ist aktiv – Wiedergabe wird nach dem PTT-Ende fortgesetzt.</string>
|
||||||
|
<string name="ptt_echo_warning">⚠ Ohne Headset kann Echo auftreten.</string>
|
||||||
|
<string name="ptt_audio_devices_button">Audiogeräte</string>
|
||||||
|
<string name="ptt_hint">Tippe ▶ zum Starten – Mikrofon wird direkt übertragen</string>
|
||||||
|
<string name="ptt_input_label">Eingabe</string>
|
||||||
|
<string name="ptt_output_label">Ausgabe</string>
|
||||||
|
<string name="ptt_device_system_default">Systemstandard</string>
|
||||||
|
|
||||||
|
<!-- Audio-Geräte-Dialog -->
|
||||||
|
<string name="audio_devices_dialog_title">Audiogeräte auswählen</string>
|
||||||
|
<string name="audio_input_device_label">Eingabegerät (Mikrofon)</string>
|
||||||
|
<string name="audio_output_device_label">Ausgabegerät (Lautsprecher)</string>
|
||||||
|
<string name="audio_devices_refresh">Geräte aktualisieren</string>
|
||||||
|
<string name="audio_devices_save">Speichern</string>
|
||||||
|
<string name="audio_devices_hint">Geräte-IDs können sich nach Neustart oder Trennen/Verbinden ändern. Bei nicht verfügbarem Gerät wird automatisch der Systemstandard verwendet.</string>
|
||||||
|
<string name="audio_devices_no_input">Keine Eingabegeräte verfügbar</string>
|
||||||
|
<string name="audio_devices_no_output">Keine Ausgabegeräte verfügbar</string>
|
||||||
|
<string name="audio_device_bluetooth">Bluetooth</string>
|
||||||
|
<string name="audio_device_usb">USB</string>
|
||||||
|
<string name="audio_device_internal_mic">Internes Mikrofon</string>
|
||||||
|
<string name="audio_device_speaker">Lautsprecher</string>
|
||||||
|
|
||||||
|
<!-- Tour-Zähler-Dialog -->
|
||||||
|
<string name="tour_counter_dialog_title">Zähler je Tour</string>
|
||||||
|
<string name="tour_counter_waypoints">%1$d Wegpunkt(e) in dieser Tour</string>
|
||||||
|
<string name="tour_counter_total_played">Gesamt abgespielt: %1$d</string>
|
||||||
|
<string name="tour_counter_reset_section">Zähler zurücksetzen</string>
|
||||||
|
<string name="tour_counter_reset_button">Alle Zähler dieser Tour zurücksetzen</string>
|
||||||
|
<string name="tour_counter_mode_section">Abspiel-Modus für alle Wegpunkte setzen</string>
|
||||||
|
<string name="tour_counter_mode_label">Abspiel-Modus</string>
|
||||||
|
<string name="tour_counter_reset_with_apply">Zähler beim Anwenden zurücksetzen</string>
|
||||||
|
<string name="tour_counter_apply_button">Auf alle Wegpunkte anwenden</string>
|
||||||
|
<string name="tour_counter_apply_hint">Gilt für alle vorhandenen Wegpunkte dieser Tour. Neue Wegpunkte erben diesen Modus automatisch als Tour-Vorgabe.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.WaypointAudioGuide" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-path name="external_files" path="." />
|
||||||
|
<files-path name="internal_files" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.suppressUnsupportedCompileSdk=35
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.5.2"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
lifecycleRuntimeKtx = "2.8.6"
|
||||||
|
activityCompose = "1.9.2"
|
||||||
|
composeBom = "2024.09.03"
|
||||||
|
datastorePreferences = "1.1.1"
|
||||||
|
gson = "2.11.0"
|
||||||
|
coroutines = "1.9.0"
|
||||||
|
locationServices = "21.3.0"
|
||||||
|
navigationCompose = "2.8.2"
|
||||||
|
osmdroid = "6.1.20"
|
||||||
|
ksp = "2.0.21-1.0.25"
|
||||||
|
media3 = "1.4.1"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
|
||||||
|
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "locationServices" }
|
||||||
|
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||||
|
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
|
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "WaypointAudioGuide"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user