340 lines
16 KiB
Kotlin
340 lines
16 KiB
Kotlin
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.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||
import androidx.compose.foundation.isSystemInDarkTheme
|
||
import androidx.compose.runtime.*
|
||
import androidx.core.content.ContextCompat
|
||
import androidx.lifecycle.lifecycleScope
|
||
import androidx.navigation.NavType
|
||
import androidx.navigation.compose.NavHost
|
||
import androidx.navigation.compose.composable
|
||
import androidx.navigation.compose.rememberNavController
|
||
import androidx.navigation.navArgument
|
||
import de.waypointaudio.data.TermsStore
|
||
import de.waypointaudio.data.TermsVersions
|
||
import de.waypointaudio.data.WakeLockSettingsStore
|
||
import de.waypointaudio.service.WakeLockController
|
||
import de.waypointaudio.ui.TermsDialog
|
||
import kotlinx.coroutines.flow.first
|
||
import kotlinx.coroutines.launch
|
||
import de.waypointaudio.ui.ExplorerScreen
|
||
import de.waypointaudio.ui.ExplorerScreenMapLibre
|
||
import de.waypointaudio.ui.HomeScreen
|
||
import de.waypointaudio.ui.MapMode
|
||
import de.waypointaudio.ui.MapScreen
|
||
import de.waypointaudio.ui.PermissionRequiredScreen
|
||
import de.waypointaudio.ui.TrackDraftEditorScreen
|
||
import de.waypointaudio.ui.TrackDraftListScreen
|
||
import de.waypointaudio.ui.VersionNoticeDialogHost
|
||
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)
|
||
|
||
/** Hält das Display abhängig von der Nutzer-Einstellung aktiv. */
|
||
lateinit var wakeLockController: WakeLockController
|
||
private set
|
||
|
||
/**
|
||
* v2.5.9 — Terms-Akzeptanz. -1 = noch nicht geladen, 0 = noch nicht akzeptiert
|
||
* (Dialog zeigen), 1 = akzeptiert (UI freigeben).
|
||
*/
|
||
private val termsState = mutableStateOf(-1)
|
||
|
||
// 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?) {
|
||
// v2.5.8 — Android Splash Screen (androidx.core.splashscreen). MUSS vor
|
||
// super.onCreate() installiert werden. Sorgt für ein kurzes, dezentes
|
||
// GPS2Audio-Intro mit Logo (Map-Pin + Wellen) auf Navy-Hintergrund.
|
||
// Wir halten den Splash bewusst nur ca. 700 ms — kein Loading-Spinner,
|
||
// keine Werbe-Pause. Auf API 31+ animiert das System das Logo automatisch
|
||
// beim Übergang. Auf API 26–30 nutzt der Backport dieselbe Optik.
|
||
val splash = installSplashScreen()
|
||
val splashStart = android.os.SystemClock.elapsedRealtime()
|
||
splash.setKeepOnScreenCondition {
|
||
android.os.SystemClock.elapsedRealtime() - splashStart < 700L
|
||
}
|
||
super.onCreate(savedInstanceState)
|
||
|
||
// Wake-Lock-Controller initialisieren
|
||
wakeLockController = WakeLockController(this)
|
||
val wakeStore = WakeLockSettingsStore(applicationContext)
|
||
lifecycleScope.launchWhenStarted {
|
||
wakeStore.settings.collect { settings ->
|
||
wakeLockController.applySettings(settings)
|
||
}
|
||
}
|
||
lifecycleScope.launchWhenStarted {
|
||
viewModel.serviceRunning.collect { running ->
|
||
wakeLockController.setTourActive(running)
|
||
}
|
||
}
|
||
|
||
// v2.5.9 — Terms-Akzeptanz laden. Solange unklar, UI nicht aufdecken.
|
||
val termsStore = TermsStore(applicationContext)
|
||
lifecycleScope.launch {
|
||
val accepted = termsStore.acceptedVersion.first()
|
||
termsState.value = if (accepted == TermsVersions.CURRENT) 1 else 0
|
||
}
|
||
|
||
// Standortberechtigungen prüfen und ggf. anfordern
|
||
checkAndRequestLocationPermissions()
|
||
|
||
setContent {
|
||
WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) {
|
||
val locationGranted by locationPermissionsGranted
|
||
val termsAccepted by termsState
|
||
|
||
// v2.5.9 — Solange der Akzeptanzstatus noch nicht geladen wurde
|
||
// (termsAccepted == -1) zeigen wir nichts; das verhindert ein
|
||
// kurzes "Aufflackern" der UI bevor der Dialog erscheint.
|
||
if (termsAccepted == -1) {
|
||
// bewusst leeres Theme — DataStore ist nach wenigen ms da.
|
||
} else if (termsAccepted == 0) {
|
||
TermsDialog(
|
||
onAccept = {
|
||
lifecycleScope.launch {
|
||
termsStore.setAccepted(TermsVersions.CURRENT)
|
||
termsState.value = 1
|
||
}
|
||
},
|
||
onDecline = {
|
||
// Ohne Zustimmung darf die App nicht weiter genutzt werden.
|
||
finishAndRemoveTask()
|
||
}
|
||
)
|
||
} else if (locationGranted) {
|
||
val navController = rememberNavController()
|
||
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = "home"
|
||
) {
|
||
// v2.5.0 — neue Hauptseite "Audio-Bibliothek".
|
||
composable("home") {
|
||
HomeScreen(
|
||
viewModel = viewModel,
|
||
onOpenTour = { _ ->
|
||
// Tour wurde im HomeScreen bereits über
|
||
// viewModel.selectTour() aktiviert. Wir öffnen
|
||
// direkt die bestehende Tour-/Waypoint-Ansicht.
|
||
navController.navigate("list")
|
||
},
|
||
// v2.7.14 — Explorer-Kontext-Fix (neutral):
|
||
// Einstieg aus Audio-Bibliothek/Home öffnet immer einen
|
||
// leeren, neutralen Kontext ("Unbenannte Tour").
|
||
// selectNeutralTourForNewExplorer() setzt selectedTour auf
|
||
// DEFAULT_TOUR_NAME und aktiviert den Neutral-Explorer-Flag,
|
||
// der verhindert, dass der combine-Kollektor automatisch
|
||
// zur ersten echten Tour wechselt.
|
||
// Einstieg aus WaypointListScreen (Touransicht) behält
|
||
// die jeweils aktive Tour — dort wird keine tour-Reset-Funktion
|
||
// aufgerufen.
|
||
onOpenExplorer = {
|
||
viewModel.selectNeutralTourForNewExplorer()
|
||
navController.navigate("explorer")
|
||
},
|
||
// v2.5.19 — „Track Editor" wurde aus dem Hauptmenü
|
||
// entfernt; Drafts bleiben über den Explorer
|
||
// erreichbar. Die „drafts"-Route bleibt für den
|
||
// Explorer-Fluss bestehen.
|
||
onOpenClassicView = { navController.navigate("list") }
|
||
)
|
||
}
|
||
composable("list") {
|
||
WaypointListScreen(
|
||
viewModel = viewModel,
|
||
onNavigateToMap = { navController.navigate("map") },
|
||
onNavigateToDrafts = { navController.navigate("drafts") },
|
||
onNavigateToExplorer = { navController.navigate("explorer") }
|
||
)
|
||
}
|
||
composable("map") {
|
||
// v2.2.3: Legacy-Editor — nur noch als interner Fallback erreichbar.
|
||
// Mode-Switch zwischen Karteneditor und Explorer entfällt, Explorer
|
||
// ist der zentrale Arbeitsbereich.
|
||
MapScreen(
|
||
viewModel = viewModel,
|
||
onNavigateBack = { navController.popBackStack() },
|
||
currentMode = null,
|
||
onModeChange = null
|
||
)
|
||
}
|
||
composable("explorer") {
|
||
// v2.4.0 — MapLibre Engine Cutover: aktiver Renderer ist jetzt MapLibre.
|
||
// Die osmdroid-Variante in [ExplorerScreen] bleibt im Quelltext als
|
||
// dokumentierter Fallback erhalten.
|
||
ExplorerScreenMapLibre(
|
||
viewModel = viewModel,
|
||
onNavigateBack = { navController.popBackStack() },
|
||
onOpenInEditor = { /* abgelöst — Explorer übernimmt */ },
|
||
onOpenDraft = { id -> navController.navigate("draft/$id") }
|
||
)
|
||
// Hinweis: ExplorerScreen (osmdroid) bleibt aus Build-/Symbol-
|
||
// Kompatibilität in der Sourcetree.
|
||
@Suppress("unused")
|
||
val osmdroidFallbackRef: @androidx.compose.runtime.Composable () -> Unit = {
|
||
ExplorerScreen(
|
||
viewModel = viewModel,
|
||
onNavigateBack = { navController.popBackStack() },
|
||
onOpenInEditor = { /* abgelöst — Explorer übernimmt */ },
|
||
onOpenDraft = { id -> navController.navigate("draft/$id") }
|
||
)
|
||
}
|
||
}
|
||
composable("drafts") {
|
||
TrackDraftListScreen(
|
||
viewModel = viewModel,
|
||
onOpenDraft = { id -> navController.navigate("draft/$id") },
|
||
onNavigateBack = { navController.popBackStack() }
|
||
)
|
||
}
|
||
composable(
|
||
route = "draft/{draftId}",
|
||
arguments = listOf(navArgument("draftId") { type = NavType.StringType })
|
||
) { backStackEntry ->
|
||
val id = backStackEntry.arguments?.getString("draftId") ?: ""
|
||
TrackDraftEditorScreen(
|
||
viewModel = viewModel,
|
||
draftId = id,
|
||
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()
|
||
}
|
||
if (::wakeLockController.isInitialized) {
|
||
wakeLockController.onActivityResumed()
|
||
}
|
||
}
|
||
|
||
override fun onPause() {
|
||
if (::wakeLockController.isInitialized) {
|
||
wakeLockController.onActivityPaused()
|
||
}
|
||
super.onPause()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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())
|
||
}
|
||
}
|
||
}
|