initial copy from nat-as app
This commit is contained in:
506
app/src/main/java/com/dano/test1/MainActivity.kt
Normal file
506
app/src/main/java/com/dano/test1/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user