Add UI XML files for current and initial app layout
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m26s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s

- Created `ui-current.xml` to represent the current state of the app's UI hierarchy.
- Created `ui.xml` to represent the initial state of the app's UI hierarchy.
This commit is contained in:
Torsten Schulz (local)
2026-06-01 10:46:39 +02:00
parent 7bc98c03e4
commit 80834d8652
26 changed files with 263 additions and 165 deletions

View File

@@ -1,11 +1,13 @@
package de.harheimertc.data
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.GeneralSecurityException
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private val tag = "SecureOfflineCache"
private companion object {
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values"
@@ -29,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
}
private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
@@ -39,6 +46,28 @@ class SecureOfflineCache @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_offline_cache")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_offline_cache",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
throw retryError
}
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)

View File

@@ -1,10 +1,12 @@
package de.harheimertc.repositories
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.security.DeviceKeyManager
import java.security.GeneralSecurityException
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
@param:ApplicationContext private val context: Context,
private val deviceKeyManager: DeviceKeyManager,
) : AuthRepository {
private val tag = "AuthRepository"
private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id"
private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_auth")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
throw retryError
}
override fun getToken(): String? = preferences.getString(tokenKey, null)

View File

@@ -1,12 +1,14 @@
package de.harheimertc.repositories
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.HomepageSectionDto
import java.security.GeneralSecurityException
import javax.inject.Inject
import javax.inject.Singleton
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private val tag = "HomeLayoutPreferences"
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (error: GeneralSecurityException) {
recoverEncryptedPreferences(error)
} catch (error: RuntimeException) {
recoverEncryptedPreferences(error)
}
private fun recoverEncryptedPreferences(error: Throwable) = try {
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
context.deleteSharedPreferences("harheimertc_home_layout")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_home_layout",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (retryError: Throwable) {
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
throw retryError
}
fun getSections(): List<HomepageSectionDto>? {

View File

@@ -6,9 +6,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.harheimertc.BuildConfig
import de.harheimertc.R
@@ -42,14 +43,17 @@ private enum class MenuSection {
TRAINING,
NEWSLETTER,
INTERN,
CMS,
}
private data class MenuTarget(val label: String, val route: String)
private const val LOGOUT_ROUTE = "__logout__"
@Composable
fun AppNavigationHeader(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit = {},
webTabletNavigation: Boolean = false,
navigationState: NavigationUiState = NavigationUiState(),
) {
@@ -61,9 +65,9 @@ fun AppNavigationHeader(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
} else {
CompactNavigation(selectedRoute, onNavigate, navigationState)
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
}
}
}
@@ -72,110 +76,53 @@ fun AppNavigationHeader(
private fun CompactNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState = NavigationUiState(),
) {
val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value
val subItems = submenu(section, navigationState)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
val mainScroll = rememberScrollState()
val subScroll = rememberScrollState()
val cmsSubScroll = rememberScrollState()
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.horizontalScroll(mainScroll),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
}
}
BrandRow(
loggedIn = navigationState.loggedIn,
onLogin = { onNavigate(Destinations.Login.route) },
onOpenCms = { sectionOverride.value = MenuSection.CMS },
)
if (mainScroll.canScrollBackward) {
Text(
"",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.CenterStart)
.background(Color(0x66000000), RoundedCornerShape(8.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
ScrollableMenuRow(scrollState = mainScroll) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (mainScroll.canScrollForward) {
Text(
"",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.CenterEnd)
.background(Color(0x66000000), RoundedCornerShape(8.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
} else {
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
if (cmsChildren.any { it.route == selectedRoute }) {
cmsExpanded.value = true
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(subScroll)
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) {
if (item.route == LOGOUT_ROUTE) {
onLogout()
} else {
onNavigate(item.route)
}
} else if (idx > cmsIndex && cmsIndex >= 0) {
// CMS children are rendered below when expanded.
} else {
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
}
}
}
if (subItems.isNotEmpty()) {
ScrollHintRow(subScroll)
}
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(cmsSubScroll)
.padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
}
}
ScrollHintRow(cmsSubScroll)
}
}
@Composable
@@ -206,12 +153,13 @@ private fun CompactSectionLink(
private fun WebTabletNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState,
) {
val section = menuSection(selectedRoute)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
// Helper that closes the CMS submenu when navigating away
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = menuSection(selectedRoute) ?: sectionOverride.value
val subScroll = rememberScrollState()
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.width(16.dp))
@@ -220,74 +168,56 @@ private fun WebTabletNavigation(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) })
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
}
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
MainLink("Intern", section == MenuSection.INTERN, onClick = { sectionOverride.value = MenuSection.INTERN })
}
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) })
}
}
val subItems = submenu(section, navigationState)
// determine CMS parent index and children
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
if (cmsChildren.any { it.route == selectedRoute }) {
cmsExpanded.value = true
}
// First row: render all subitems but do NOT render CMS children here
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
// CMS parent toggle
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
}
} else if (idx > cmsIndex && cmsIndex >= 0) {
// skip cms children here; they'll be rendered in the second row when expanded
} else {
// normal item before CMS: close cms submenu on navigate
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
}
Spacer(Modifier.width(12.dp))
if (navigationState.loggedIn) {
TextButton(onClick = { sectionOverride.value = MenuSection.CMS }) { Text("CMS", color = Color.White) }
} else {
TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) }
}
}
// Second row: when CMS expanded, render its children beneath
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
val subItems = submenu(section, navigationState)
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) {
if (item.route == LOGOUT_ROUTE) {
onLogout()
} else {
onNavigate(item.route)
}
}
}
}
}
@Composable
private fun BrandRow(onLogin: () -> Unit) {
private fun BrandRow(
loggedIn: Boolean,
onLogin: () -> Unit,
onOpenCms: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.weight(1f))
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
if (loggedIn) {
TextButton(onClick = onOpenCms) { Text("CMS", color = Color.White) }
} else {
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
}
}
}
@@ -367,25 +297,36 @@ private fun CompactLink(
}
@Composable
private fun ScrollHintRow(scrollState: ScrollState) {
if (!scrollState.canScrollBackward && !scrollState.canScrollForward) return
private fun ScrollableMenuRow(
scrollState: ScrollState,
topPadding: Dp = 0.dp,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
.padding(top = topPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (scrollState.canScrollBackward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
Row(
modifier = Modifier
.weight(1f)
.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
Text(
if (scrollState.canScrollForward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
}
}
@@ -431,12 +372,14 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.NewsletterConfirm.route,
Destinations.NewsletterConfirmed.route,
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.Qttr.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.MemberApi.route,
Destinations.MemberApi.route -> MenuSection.INTERN
Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route,
@@ -447,7 +390,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.CmsEinstellungen.route,
Destinations.CmsBenutzer.route,
Destinations.CmsPasswordResetDiagnostics.route,
Destinations.Cms.route -> MenuSection.INTERN
Destinations.Cms.route -> MenuSection.CMS
else -> null
}.let { section ->
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
@@ -464,23 +408,27 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuTarget("Links", Destinations.Links.route),
MenuTarget("Impressum", Destinations.Impressum.route),
)
MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route),
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
MenuTarget("Spielpläne", Destinations.Spielplan.route),
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
)
MenuSection.TRAINING -> listOf(
MenuTarget("Trainingszeiten", Destinations.Training.route),
MenuTarget("Trainer", Destinations.Trainer.route),
MenuTarget("Anfänger", Destinations.Anfaenger.route),
MenuTarget("TT-Regeln", Destinations.Regeln.route),
)
MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
)
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
@@ -488,19 +436,23 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
}
MenuSection.CMS -> buildList {
add(MenuTarget("Übersicht", Destinations.Cms.route))
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
add(MenuTarget("Ausloggen", LOGOUT_ROUTE))
}
null -> emptyList()
}

View File

@@ -39,6 +39,13 @@ fun NavGraph(
AppNavigationHeader(
selectedRoute = currentRoute,
onNavigate = navController::navigateTopLevel,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
webTabletNavigation = true,
navigationState = navigationState,
)
@@ -52,6 +59,8 @@ fun NavGraph(
de.harheimertc.ui.screens.home.HomeScreen(
navController = navController,
showNavigationHeader = !persistentNavigation,
navigationViewModel = navigationViewModel,
viewModel = hiltViewModel(),
)
}
composable(Destinations.VereinAbout.route) {

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft
@@ -30,6 +31,7 @@ class NavigationViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository,
private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository,
private val authRepository: AuthRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state
@@ -44,10 +46,11 @@ class NavigationViewModel @Inject constructor(
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await()
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
_state.value = NavigationUiState(
teams = teams.await(),
hasGalleryImages = gallery.await(),
loggedIn = status.isLoggedIn,
loggedIn = hasStoredSession || status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
)
}
@@ -56,10 +59,22 @@ class NavigationViewModel @Inject constructor(
fun refreshSession() {
viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
_state.value = _state.value.copy(
loggedIn = status.isLoggedIn,
loggedIn = hasStoredSession || status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
)
}
}
fun logout(onComplete: () -> Unit = {}) {
viewModelScope.launch {
loginRepository.logout()
_state.value = _state.value.copy(
loggedIn = false,
roles = emptySet(),
)
onComplete()
}
}
}

View File

@@ -49,7 +49,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
@@ -77,9 +76,9 @@ import java.util.Locale
fun HomeScreen(
navController: NavController,
showNavigationHeader: Boolean = true,
viewModel: HomeViewModel = hiltViewModel(),
navigationViewModel: NavigationViewModel,
viewModel: HomeViewModel,
) {
val navigationViewModel: NavigationViewModel = hiltViewModel()
val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
@@ -107,6 +106,13 @@ fun HomeScreen(
AppNavigationHeader(
selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
navigationState = navigationState,
)
}