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()) } } }