initial copy from nat-as app

This commit is contained in:
2026-06-24 16:04:59 +02:00
commit db5757997f
257 changed files with 20329 additions and 0 deletions

View File

@ -0,0 +1,506 @@
package com.dano.test1
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.dano.test1.LanguageManager
import com.dano.test1.ui.AppLanguageStore
import com.dano.test1.ui.NotificationPermissionPrompter
import com.dano.test1.ui.AuthActivity
import com.dano.test1.ui.HandlerOpeningScreen
import com.dano.test1.ui.LogoutDialogs
import com.dano.test1.ui.SessionLogout
import com.dano.test1.ui.SystemBarInsets
import com.dano.test1.utils.SyncProgressOverlay
import com.dano.test1.utils.SyncProgressTexts
import com.dano.test1.utils.ViewUtils
import com.dano.test1.data.PendingUploadRepository
import com.dano.test1.network.NetworkUtils
import com.dano.test1.network.QuestionnaireApiClient
import com.dano.test1.network.SyncLog
import com.dano.test1.network.QuestionnaireCache
import com.dano.test1.network.SyncCoordinator
import com.dano.test1.network.SyncProgress
import com.dano.test1.network.SyncResult
import com.dano.test1.network.TokenStore
import com.dano.test1.questionnaire.QuestionnaireBase
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private companion object {
private const val STARTUP_SYNC_PREFS = "startup_sync_state"
private const val KEY_LAST_STARTUP_SYNC_MS = "last_startup_sync_ms"
private const val STARTUP_SYNC_INTERVAL_MS = 30L * 60L * 1000L
}
private lateinit var openingScreenHandler: HandlerOpeningScreen
var isInQuestionnaire: Boolean = false
var isFirstQuestionnairePage: Boolean = false
/** Set before opening DevSettings so onResume runs a full opening-screen rebuild. */
private var needsOpeningReinit: Boolean = false
private var assignedClientsRefreshGeneration: Long = 0L
fun markOpeningScreenReinit() {
needsOpeningReinit = true
}
fun bumpAssignedClientsRefreshGeneration() {
assignedClientsRefreshGeneration++
}
private var syncProgressOverlay: SyncProgressOverlay? = null
private var backPressInterceptor: (() -> Boolean)? = null
private var notificationPromptRequested = false
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
private val authLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != RESULT_OK) {
finishAffinity()
return@registerForActivityResult
}
val token = result.data?.getStringExtra(AuthActivity.EXTRA_TOKEN)
if (token.isNullOrBlank()) {
finishAffinity()
return@registerForActivityResult
}
proceedAfterLogin(token)
}
// LIVE: Network-Callback (optional für Statusleiste)
private var netCb: ConnectivityManager.NetworkCallback? = null
private val bootLanguageId: String get() = AppLanguageStore.get(this)
private fun t(key: String): String = LanguageManager.getText(bootLanguageId, key)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
isInQuestionnaire -> confirmExitQuestionnaire()
backPressInterceptor?.invoke() == true -> Unit
::openingScreenHandler.isInitialized && openingScreenHandler.onBackPressed() -> Unit
else -> {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
}
})
TokenStore.ensureLegacyLoginTimestamp(this)
setContentView(R.layout.activity_startup_loading)
// === Offline-Start ermöglichen ===
// Bedingung: gespeicherter User/Token UND lokaler API-Cache vorhanden -> direkt OpeningScreen
val hasCreds = !TokenStore.getUsername(this).isNullOrBlank() && TokenStore.hasToken(this)
val hasCache = QuestionnaireCache.hasQuestionnaireCache(this)
SyncLog.d("MAIN", "onCreate: hasCreds=$hasCreds, hasCache=$hasCache")
SyncLog.d("MAIN", "onCreate: cached assigned clients count=${QuestionnaireCache.getAssignedClients(this).size}")
if (hasCreds && hasCache) {
validateStoredSessionThenOpen()
return
}
SyncCoordinator.loadCachedTranslations(this)
CoroutineScope(Dispatchers.Main).launch {
SyncCoordinator.refreshPublicTranslations(this@MainActivity)
}
// Sonst: Login-Screen -> Login -> Daten laden -> OpeningScreen
launchAuthScreen()
}
private fun validateStoredSessionThenOpen() {
val token = TokenStore.getToken(this)
if (token.isNullOrBlank() || !TokenStore.isSessionValid(this)) {
requestLogin()
return
}
if (!NetworkUtils.isOnline(this)) {
showCachedOpeningScreen(refreshAfterOpen = false)
return
}
CoroutineScope(Dispatchers.Main).launch {
val sessionCheck = withContext(Dispatchers.IO) {
runCatching { QuestionnaireApiClient.getSession(token) }
}
val session = sessionCheck.getOrNull()
val error = sessionCheck.exceptionOrNull()
if (session != null && session.valid && session.role == "coach" && !session.mustChangePassword) {
routeCachedStartup(token)
} else if (error != null && TokenStore.isAuthError(error)) {
requestLogin()
} else if (error == null) {
requestLogin()
} else {
// Network/server transient failure: keep offline-capable cached mode.
showCachedOpeningScreen(refreshAfterOpen = false)
}
}
}
private suspend fun routeCachedStartup(token: String) {
val hasPendingUploads = withContext(Dispatchers.IO) {
PendingUploadRepository.hasAny()
}
if (hasPendingUploads || !NetworkUtils.isOnline(this)) {
showCachedOpeningScreen(refreshAfterOpen = false, showUploadPrompt = hasPendingUploads)
return
}
if (!shouldRunStartupSync()) {
showCachedOpeningScreen(refreshAfterOpen = false)
return
}
showSyncProgressOverlay(bootLanguageId)
val syncResult = withContext(Dispatchers.IO) {
SyncCoordinator.refreshManual(
context = this@MainActivity,
token = token,
onProgress = { reportSyncProgress(it) },
)
}
hideSyncProgressOverlay()
when (syncResult) {
SyncResult.AuthRequired -> {
requestLogin()
return
}
else -> {
if (syncResult is SyncResult.Success) {
markSuccessfulSync()
}
showCachedOpeningScreen(refreshAfterOpen = false)
if (syncResult is SyncResult.Failure) {
SyncCoordinator.toastForResult(this, bootLanguageId, syncResult)
}
}
}
}
private fun shouldRunStartupSync(): Boolean {
val lastSyncAt = getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE)
.getLong(KEY_LAST_STARTUP_SYNC_MS, 0L)
return lastSyncAt <= 0L ||
System.currentTimeMillis() - lastSyncAt >= STARTUP_SYNC_INTERVAL_MS
}
fun markSuccessfulSync() {
getSharedPreferences(STARTUP_SYNC_PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_LAST_STARTUP_SYNC_MS, System.currentTimeMillis())
.apply()
}
private fun showCachedOpeningScreen(refreshAfterOpen: Boolean, showUploadPrompt: Boolean = false) {
SyncCoordinator.loadCachedTranslations(this)
openingScreenHandler = HandlerOpeningScreen(this)
openingScreenHandler.init()
if (showUploadPrompt) {
openingScreenHandler.runAfterFirstClientLoad {
openingScreenHandler.showStartupUploadPrompt()
}
}
if (refreshAfterOpen) {
openingScreenHandler.runAfterFirstClientLoad {
refreshAssignedClientsInBackground()
}
}
scheduleNotificationPermissionPrompt()
}
/** Refreshes assigned clients from the server when online (e.g. after app restart). */
private fun refreshAssignedClientsInBackground() {
val token = TokenStore.getToken(this) ?: return
if (!NetworkUtils.isOnline(this)) {
SyncLog.d("MAIN", "refreshAssignedClients skipped: offline")
return
}
val generation = assignedClientsRefreshGeneration
CoroutineScope(Dispatchers.IO).launch {
when (val result = SyncCoordinator.refreshClientsOnly(this@MainActivity, token)) {
SyncResult.AuthRequired -> {
withContext(Dispatchers.Main) { requestLogin() }
return@launch
}
SyncResult.Success -> {
withContext(Dispatchers.Main) {
if (generation != assignedClientsRefreshGeneration) return@withContext
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.onAssignedClientsSynced()
}
}
}
else -> {}
}
}
}
/**
* Clears all local data and shows the login screen (e.g. expired token).
* Does not check pending uploads — session is already invalid.
*/
fun requestLogin() {
isInQuestionnaire = false
CoroutineScope(Dispatchers.Main).launch {
val previousUser = TokenStore.getUsername(this@MainActivity)
withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) }
launchAuthScreen(prefillUsername = previousUser)
}
}
/** User-initiated logout from settings; blocked when offline or unuploaded data remains. */
fun attemptLogout(languageId: String? = null) {
val lang = languageId ?: bootLanguageId
CoroutineScope(Dispatchers.Main).launch {
val block = withContext(Dispatchers.IO) { SessionLogout.evaluate(this@MainActivity) }
when (block) {
null -> LogoutDialogs.showConfirm(this@MainActivity, lang) {
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) { SessionLogout.wipeAllLocalData(this@MainActivity) }
isInQuestionnaire = false
launchAuthScreen()
}
}
SessionLogout.BlockReason.Offline -> LogoutDialogs.showBlocked(
activity = this@MainActivity,
languageId = lang,
title = SessionLogout.message(lang, "logout_offline_title", "Cannot log out"),
message = SessionLogout.message(
lang,
"logout_offline_message",
"You are offline. Connect to the internet, upload pending data, then try again.",
),
tone = LogoutDialogs.Tone.Info,
)
is SessionLogout.BlockReason.UnuploadedWork -> {
val details = SessionLogout.formatPendingDetails(lang, block.summary)
val bodyTemplate = SessionLogout.message(
lang,
"logout_pending_message",
"You still have data on this device that has not been uploaded.\n\n{details}\n\nPlease tap Upload and wait until it finishes.",
)
val message = bodyTemplate.replace("{details}", "").trim()
LogoutDialogs.showBlocked(
activity = this@MainActivity,
languageId = lang,
title = SessionLogout.message(lang, "logout_pending_title", "Cannot log out"),
message = message,
details = details,
tone = LogoutDialogs.Tone.Warning,
)
}
}
}
}
private fun launchAuthScreen(prefillUsername: String? = null) {
val intent = Intent(this, AuthActivity::class.java)
prefillUsername?.takeIf { it.isNotBlank() }?.let {
intent.putExtra(AuthActivity.EXTRA_PREFILL_USERNAME, it)
}
authLauncher.launch(intent)
}
fun showSyncProgressOverlay(languageId: String? = null) {
val locale = SyncProgressTexts.Locale.fromLanguageId(languageId ?: bootLanguageId)
ensureSyncProgressOverlay().setLocale(locale)
ensureSyncProgressOverlay().show()
}
fun hideSyncProgressOverlay() {
syncProgressOverlay?.hide()
}
fun reportSyncProgress(progress: SyncProgress) {
runOnUiThread { ensureSyncProgressOverlay().update(progress) }
}
private fun ensureSyncProgressOverlay(): SyncProgressOverlay {
if (syncProgressOverlay == null) {
syncProgressOverlay = SyncProgressOverlay(this)
}
return syncProgressOverlay!!
}
private fun proceedAfterLogin(token: String) {
SyncLog.d("MAIN", "Login success")
SyncLog.d("MAIN", "Assigned clients from cache after login count=${QuestionnaireCache.getAssignedClients(this).size}")
showSyncProgressOverlay(bootLanguageId)
CoroutineScope(Dispatchers.IO).launch {
val syncResult = SyncCoordinator.refreshAfterLogin(
context = this@MainActivity,
token = token,
languageId = bootLanguageId,
onProgress = { reportSyncProgress(it) },
)
withContext(Dispatchers.Main) {
hideSyncProgressOverlay()
when (syncResult) {
SyncResult.AuthRequired -> {
requestLogin()
return@withContext
}
is SyncResult.Failure -> {
SyncCoordinator.toastForResult(this@MainActivity, bootLanguageId, syncResult)
}
SyncResult.Success -> markSuccessfulSync()
else -> {}
}
openingScreenHandler = HandlerOpeningScreen(this@MainActivity)
openingScreenHandler.init()
openingScreenHandler.refreshHeaderStatusLive()
scheduleNotificationPermissionPrompt()
}
}
}
fun setBackPressInterceptor(interceptor: (() -> Boolean)?) {
backPressInterceptor = interceptor
}
private fun scheduleNotificationPermissionPrompt() {
if (notificationPromptRequested) return
notificationPromptRequested = true
window.decorView.post {
NotificationPermissionPrompter.showIfNeeded(this, requestNotificationPermission)
}
}
private fun confirmExitQuestionnaire() {
fun td(key: String, fallbackDe: String, fallbackEn: String): String {
val raw = LanguageManager.getText(bootLanguageId, key)
if (raw != key) return raw
return if (bootLanguageId == "GERMAN") fallbackDe else fallbackEn
}
MaterialAlertDialogBuilder(this)
.setTitle(td("questionnaire_exit_title", "Fragebogen verlassen?", "Leave questionnaire?"))
.setMessage(
td(
"questionnaire_exit_message",
"Nicht gespeicherte Eingaben in diesem Schritt können verloren gehen.",
"Unsaved answers on this screen may be lost.",
),
)
.setNegativeButton(td("cancel", "Abbrechen", "Cancel")) { d, _ -> d.dismiss() }
.setPositiveButton(td("leave", "Verlassen", "Leave")) { _, _ -> finishQuestionnaire() }
.show()
}
// --- LIVE NETZSTATUS (optional, für deine Status-Leiste) ---
override fun onResume() {
super.onResume()
registerNetworkCallback()
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
if (needsOpeningReinit) {
needsOpeningReinit = false
openingScreenHandler.init()
} else {
openingScreenHandler.refreshHeaderStatusLive()
}
if (QuestionnaireCache.getAssignedClients(this).isEmpty()) {
refreshAssignedClientsInBackground()
}
}
}
override fun onPause() {
super.onPause()
unregisterNetworkCallback()
}
private fun registerNetworkCallback() {
if (netCb != null) return
val cm = getSystemService(ConnectivityManager::class.java)
val req = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
netCb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
override fun onLost(network: Network) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
runOnUiThread {
if (::openingScreenHandler.isInitialized && !isInQuestionnaire) {
openingScreenHandler.refreshHeaderStatusLive()
}
}
}
}
cm.registerNetworkCallback(req, netCb!!)
}
private fun unregisterNetworkCallback() {
val cb = netCb ?: return
val cm = getSystemService(ConnectivityManager::class.java)
cm.unregisterNetworkCallback(cb)
netCb = null
}
// --- /LIVE NETZSTATUS ---
override fun onContentChanged() {
super.onContentChanged()
SystemBarInsets.applyToContentRoot(this, ensureMinContentPadding = true)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
}
fun startQuestionnaire(questionnaire: QuestionnaireBase<*>, languageID: String) {
isInQuestionnaire = true
isFirstQuestionnairePage = true
questionnaire.attach(this, languageID)
questionnaire.startQuestionnaire()
}
fun finishQuestionnaire() {
isInQuestionnaire = false
isFirstQuestionnairePage = false
ViewUtils.setTextSizeScale(1f)
LanguageManager.clearQuestionnaireTranslations()
if (::openingScreenHandler.isInitialized) {
openingScreenHandler.init()
}
}
}