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 package de.harheimertc.data
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -14,6 +16,7 @@ class SecureOfflineCache @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val moshi: Moshi, private val moshi: Moshi,
) { ) {
private val tag = "SecureOfflineCache"
private companion object { private companion object {
const val KEY_BIRTHDAYS = "birthdays" const val KEY_BIRTHDAYS = "birthdays"
const val KEY_QTTR_VALUES = "qttr_values" const val KEY_QTTR_VALUES = "qttr_values"
@@ -29,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
} }
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -39,6 +46,28 @@ class SecureOfflineCache @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, 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) fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)

View File

@@ -1,10 +1,12 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.security.DeviceKeyManager import de.harheimertc.security.DeviceKeyManager
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val deviceKeyManager: DeviceKeyManager, private val deviceKeyManager: DeviceKeyManager,
) : AuthRepository { ) : AuthRepository {
private val tag = "AuthRepository"
private val tokenKey = "auth_token" private val tokenKey = "auth_token"
private val refreshTokenKey = "auth_refresh_token" private val refreshTokenKey = "auth_refresh_token"
private val sessionIdKey = "auth_session_id" private val sessionIdKey = "auth_session_id"
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, 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) override fun getToken(): String? = preferences.getString(tokenKey, null)

View File

@@ -1,12 +1,14 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.content.Context import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.HomepageSectionDto
import java.security.GeneralSecurityException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val moshi: Moshi, private val moshi: Moshi,
) { ) {
private val tag = "HomeLayoutPreferences"
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java) private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType) private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
private val preferences by lazy { private val preferences by lazy {
buildEncryptedPreferences()
}
private fun buildEncryptedPreferences() = try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, 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>? { fun getSections(): List<HomepageSectionDto>? {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
@@ -30,6 +31,7 @@ class NavigationViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository, private val mannschaftenRepository: MannschaftenRepository,
private val galleryRepository: GalleryRepository, private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val authRepository: AuthRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState()) private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state val state: StateFlow<NavigationUiState> = _state
@@ -44,10 +46,11 @@ class NavigationViewModel @Inject constructor(
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) } val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) } val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await() val status = auth.await()
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
_state.value = NavigationUiState( _state.value = NavigationUiState(
teams = teams.await(), teams = teams.await(),
hasGalleryImages = gallery.await(), hasGalleryImages = gallery.await(),
loggedIn = status.isLoggedIn, loggedIn = hasStoredSession || status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
) )
} }
@@ -56,10 +59,22 @@ class NavigationViewModel @Inject constructor(
fun refreshSession() { fun refreshSession() {
viewModelScope.launch { viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
_state.value = _state.value.copy( _state.value = _state.value.copy(
loggedIn = status.isLoggedIn, loggedIn = hasStoredSession || status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -77,9 +76,9 @@ import java.util.Locale
fun HomeScreen( fun HomeScreen(
navController: NavController, navController: NavController,
showNavigationHeader: Boolean = true, showNavigationHeader: Boolean = true,
viewModel: HomeViewModel = hiltViewModel(), navigationViewModel: NavigationViewModel,
viewModel: HomeViewModel,
) { ) {
val navigationViewModel: NavigationViewModel = hiltViewModel()
val navigationState by navigationViewModel.state.collectAsState() val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) } var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
@@ -107,6 +106,13 @@ fun HomeScreen(
AppNavigationHeader( AppNavigationHeader(
selectedRoute = Destinations.Home.route, selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate, onNavigate = navController::navigate,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
navigationState = navigationState, navigationState = navigationState,
) )
} }

View File

@@ -0,0 +1,25 @@
export default defineNuxtPlugin((nuxtApp) => {
const authStore = useAuthStore()
const syncAuthState = async () => {
await authStore.checkAuth()
}
nuxtApp.hook('app:mounted', () => {
syncAuthState()
})
if (typeof window !== 'undefined') {
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
syncAuthState()
}
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncAuthState()
}
})
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

BIN
temp/device-35433-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

BIN
temp/device-38281-cms-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

BIN
temp/device-38281-cms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

BIN
temp/device-38281.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

1
temp/ui-35433.xml Normal file

File diff suppressed because one or more lines are too long

1
temp/ui-cms.xml Normal file

File diff suppressed because one or more lines are too long

1
temp/ui-current.xml Normal file

File diff suppressed because one or more lines are too long

1
temp/ui.xml Normal file

File diff suppressed because one or more lines are too long