Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||
.orElse("http://10.0.2.2:3100/")
|
||||
.orElse("https://harheimertc.tsschulz.de/")
|
||||
.get()
|
||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||
.orElse("")
|
||||
@@ -46,7 +46,7 @@ android {
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
@@ -131,4 +131,14 @@ dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
testImplementation("io.mockk:mockk:1.13.7")
|
||||
// Compose UI testing
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
// Hilt testing
|
||||
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
|
||||
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated
|
||||
androidTestImplementation("com.google.dagger:hilt-android:2.59.2")
|
||||
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.0")
|
||||
}
|
||||
|
||||
9
android-app/app/src/androidTest/AndroidManifest.xml
Normal file
9
android-app/app/src/androidTest/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name="dagger.hilt.android.testing.HiltTestApplication"
|
||||
android:allowBackup="false">
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Disabled TestBindingsModule — replaced by TestHiltModules.kt
|
||||
// Kept as an empty placeholder to avoid accidental compilation of the previous
|
||||
// broken test module. Refer to TestHiltModules.kt for test bindings.
|
||||
package de.harheimertc.test
|
||||
|
||||
// Intentionally empty
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.harheimertc.test
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthUserDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import dagger.hilt.InstallIn
|
||||
import retrofit2.Response
|
||||
import javax.inject.Singleton
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.repositories.AuthRepository as RepoAuthRepository
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [de.harheimertc.data.NetworkModule::class, de.harheimertc.di.RepositoryModule::class]
|
||||
)
|
||||
object TestHiltModules {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder().build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(): ApiService {
|
||||
val handler = InvocationHandler { _, method: Method, args: Array<Any>? ->
|
||||
when (method.name) {
|
||||
"login" -> Response.success(LoginResponse(success = true, accessToken = "test-token", refreshToken = "r", sessionId = "s", user = AuthUserDto(id = "1", email = "test@example.com", name = "Test")))
|
||||
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
|
||||
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
|
||||
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
|
||||
}
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
ApiService::class.java.classLoader,
|
||||
arrayOf(ApiService::class.java),
|
||||
handler,
|
||||
) as ApiService
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(): AuthRepository = object : AuthRepository {
|
||||
private var token: String? = "test-token"
|
||||
private var refresh: String? = "r"
|
||||
override fun getToken(): String? = token
|
||||
override fun getRefreshToken(): String? = refresh
|
||||
override fun getSessionId(): String? = "s"
|
||||
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||
token = accessToken
|
||||
refresh = refreshToken
|
||||
}
|
||||
|
||||
override fun clearSession() { token = null; refresh = null }
|
||||
override fun ensureDeviceKey(): String? = null
|
||||
override fun getDevicePublicKey(): String? = null
|
||||
override fun signWithDeviceKey(data: ByteArray): ByteArray? = null
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionRefresher(auth: AuthRepository, moshi: Moshi): SessionRefresher = SessionRefresher(auth, moshi)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoginRepository(api: ApiService, auth: AuthRepository, sessionRefresher: SessionRefresher): LoginRepository {
|
||||
return LoginRepository(api, auth, sessionRefresher)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePasskeyRepository(api: ApiService, auth: AuthRepository): PasskeyRepository {
|
||||
return PasskeyRepository(api, auth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.harheimertc.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
class TestActivity : ComponentActivity()
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsActivateResendTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun activateAndResend_buttonsAreClickable() {
|
||||
composeTestRule.setContent {
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Deaktivieren") }
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Invite erneut") }
|
||||
}
|
||||
|
||||
// wait until nodes appear to avoid race conditions on slower devices
|
||||
fun waitForText(text: String, timeoutMs: Long = 15000L) {
|
||||
try {
|
||||
composeTestRule.waitUntil(timeoutMs) {
|
||||
try {
|
||||
composeTestRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (_: AssertionError) {
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// dump semantics tree for debugging before failing
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE")
|
||||
} catch (_: Throwable) { /* best-effort logging */ }
|
||||
throw AssertionError("Timed out waiting for text: '$text'")
|
||||
}
|
||||
}
|
||||
|
||||
// helper: find the nearest parent node that has a click action
|
||||
fun findClickableParent(text: String): SemanticsNodeInteraction {
|
||||
val all = composeTestRule.onAllNodes(hasText(text))
|
||||
if (all.fetchSemanticsNodes().isEmpty()) {
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NOT-FOUND-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No node found with text '$text'")
|
||||
}
|
||||
|
||||
// Log matches for debugging
|
||||
try {
|
||||
val matches = all.fetchSemanticsNodes()
|
||||
Log.d("CmsActivateResendTest", "Found ${matches.size} node(s) for text '$text'")
|
||||
matches.forEachIndexed { i, n -> Log.d("CmsActivateResendTest", "Match[$i]: ${n}") }
|
||||
} catch (_: Throwable) { /* ignore logging failures */ }
|
||||
|
||||
var node = try {
|
||||
// prefer the single-node API, but fall back to the first match if ambiguous
|
||||
composeTestRule.onNode(hasText(text))
|
||||
} catch (_: AssertionError) {
|
||||
all[0]
|
||||
}
|
||||
|
||||
// climb a few parents to find the clickable wrapper
|
||||
repeat(8) {
|
||||
try {
|
||||
node.assert(hasClickAction())
|
||||
try { Log.d("CmsActivateResendTest", "Clickable node found for '$text': ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
return node
|
||||
} catch (_: AssertionError) {
|
||||
try { Log.d("CmsActivateResendTest", "Node not clickable yet, current node: ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
node = node.onParent()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NO-CLICK-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No clickable parent found for text '$text'")
|
||||
}
|
||||
|
||||
waitForText("Deaktivieren")
|
||||
val deactivateNode = findClickableParent("Deaktivieren")
|
||||
deactivateNode.assertExists()
|
||||
deactivateNode.assertIsDisplayed()
|
||||
deactivateNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
deactivateNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-DEACTIVATE")
|
||||
throw e
|
||||
}
|
||||
|
||||
waitForText("Invite erneut")
|
||||
val inviteNode = findClickableParent("Invite erneut")
|
||||
inviteNode.assertExists()
|
||||
inviteNode.assertIsDisplayed()
|
||||
inviteNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
inviteNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-INVITE")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsRolesDialogTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
class FakeVm {
|
||||
var calledId: String? = null
|
||||
var calledRoles: List<String>? = null
|
||||
fun updateUserRoles(id: String, roles: List<String>) {
|
||||
calledId = id
|
||||
calledRoles = roles
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rolesDialog_callsUpdateUserRoles() {
|
||||
val fake = FakeVm()
|
||||
val initialRoles = listOf("admin")
|
||||
|
||||
composeTestRule.setContent {
|
||||
val show = remember { mutableStateOf(false) }
|
||||
val selected = remember { mutableStateListOf<String>().apply { addAll(initialRoles) } }
|
||||
Column {
|
||||
Button(onClick = { show.value = true }) { Text("Rollen") }
|
||||
if (show.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { show.value = false },
|
||||
title = { Text("Rollen bearbeiten") },
|
||||
text = {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
// simple checkbox row for admin only (representative)
|
||||
Row {
|
||||
Checkbox(checked = selected.contains("admin"), onCheckedChange = { checked ->
|
||||
if (checked) selected.add("admin") else selected.remove("admin")
|
||||
})
|
||||
Text("admin", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
fake.updateUserRoles("42", selected.toList())
|
||||
show.value = false
|
||||
}) { Text("Speichern") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { show.value = false }) { Text("Abbrechen") } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open dialog
|
||||
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||
// Save immediately (we keep admin preselected)
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
|
||||
assert(fake.calledId == "42")
|
||||
assert(fake.calledRoles?.contains("admin") == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsScreen_placeholder() {
|
||||
composeTestRule.setContent {
|
||||
Text("CMS Placeholder")
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("CMS Placeholder").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GalleryScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun galleryScreen_rendersPlaceholder() {
|
||||
composeTestRule.setContent {
|
||||
GalleryScreen()
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HomeScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun homeScreen_renders() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
HomeScreen(navController = navController, showNavigationHeader = false)
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LoginScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun loginScreen_showsFields() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
LoginScreen(navController = navController, showBackNavigation = false)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("E-Mail-Adresse", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Passwort", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Anmelden", useUnmergedTree = true).assertExists()
|
||||
}
|
||||
}
|
||||
8
android-app/app/src/debug/AndroidManifest.xml
Normal file
8
android-app/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Disable Sentry automatic initialization in debug/test builds -->
|
||||
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||
<meta-data android:name="io.sentry.dsn" android:value="" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -6,8 +6,10 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
@@ -17,7 +19,7 @@ import okhttp3.ResponseBody
|
||||
import okhttp3.RequestBody
|
||||
|
||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
@@ -557,9 +559,29 @@ interface ApiService {
|
||||
@GET("/api/cms/users/list")
|
||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||
|
||||
data class UpdateUserRolesRequest(val id: String, val roles: List<String>)
|
||||
data class UpdateUserActiveRequest(val id: String, val active: Boolean)
|
||||
|
||||
@PUT("/api/cms/users/update-roles")
|
||||
suspend fun updateUserRoles(@Body request: UpdateUserRolesRequest): Response<AuthMessageResponse>
|
||||
|
||||
@PUT("/api/cms/users/update-active")
|
||||
suspend fun updateUserActive(@Body request: UpdateUserActiveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/cms/users/resend-invite")
|
||||
suspend fun resendInvite(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/contact-requests")
|
||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||
|
||||
data class ContactReplyRequest(val message: String)
|
||||
|
||||
@POST("/api/cms/contact-requests/{id}/reply")
|
||||
suspend fun replyToContactRequest(@Path("id") id: String, @Body request: ContactReplyRequest): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@PATCH("/api/cms/contact-requests/{id}/toggle-status")
|
||||
suspend fun toggleContactRequestStatus(@Path("id") id: String): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@GET("/api/newsletter/list")
|
||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ object NetworkModule {
|
||||
cache: Cache,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
||||
logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
|
||||
val cookies = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
@@ -84,8 +84,10 @@ object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
||||
val runtimeBase = BuildConfig.API_BASE_URL
|
||||
android.util.Log.i("NetworkModule", "Retrofit baseUrl runtime=$runtimeBase")
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.baseUrl(runtimeBase)
|
||||
.client(client)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
|
||||
@@ -44,6 +44,26 @@ class CmsRepository @Inject constructor(
|
||||
fallbackMessage = "Benutzer konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun updateUserRoles(id: String, roles: List<String>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val req = de.harheimertc.data.ApiService.UpdateUserRolesRequest(id, roles)
|
||||
val response = api.updateUserRoles(req)
|
||||
if (!response.isSuccessful) error("Benutzerrollen konnten nicht aktualisiert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun updateUserActive(id: String, active: Boolean): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val req = de.harheimertc.data.ApiService.UpdateUserActiveRequest(id, active)
|
||||
val response = api.updateUserActive(req)
|
||||
if (!response.isSuccessful) error("Benutzerstatus konnte nicht aktualisiert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun resendInvite(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.resendInvite(id)
|
||||
if (!response.isSuccessful) error("Einladung konnte nicht erneut gesendet werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
@@ -56,6 +76,19 @@ class CmsRepository @Inject constructor(
|
||||
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun replyToContactRequest(id: String, message: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||
val req = ApiService.ContactReplyRequest(message)
|
||||
val response = api.replyToContactRequest(id, req)
|
||||
if (!response.isSuccessful) error("Antwort konnte nicht gesendet werden.")
|
||||
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||
}
|
||||
|
||||
suspend fun toggleContactRequestStatus(id: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||
val response = api.toggleContactRequestStatus(id)
|
||||
if (!response.isSuccessful) error("Status konnte nicht geändert werden.")
|
||||
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||
}
|
||||
|
||||
suspend fun newsletters(): Result<NewsletterListResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
@@ -92,6 +125,30 @@ class CmsRepository @Inject constructor(
|
||||
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun news(): Result<de.harheimertc.data.NewsResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||
response.body() ?: de.harheimertc.data.NewsResponse()
|
||||
},
|
||||
save = cache::putNews,
|
||||
cached = cache::getNews,
|
||||
fallbackMessage = "News konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun saveNews(request: de.harheimertc.data.NewsSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
private suspend fun <T> fetchEncryptedFallback(
|
||||
load: suspend () -> T,
|
||||
save: (T) -> Unit,
|
||||
|
||||
@@ -40,8 +40,19 @@ class MemberAreaRepository @Inject constructor(
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
if (!response.isSuccessful) {
|
||||
try {
|
||||
val body = response.errorBody()?.string()
|
||||
android.util.Log.w("MemberAreaRepository", "memberNews failed: code=${response.code()} body=${body?.take(500)}")
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
error("News konnten nicht geladen werden.")
|
||||
}
|
||||
response.body() ?: run {
|
||||
android.util.Log.w("MemberAreaRepository", "memberNews: successful but empty body (null)")
|
||||
NewsResponse(success = false, news = emptyList())
|
||||
}
|
||||
},
|
||||
save = cache::putNews,
|
||||
cached = cache::getNews,
|
||||
|
||||
@@ -202,16 +202,4 @@ private fun selectedText(value: TextFieldValue): String {
|
||||
return value.text.substring(start, end)
|
||||
}
|
||||
|
||||
private fun normalizeEmptyHtml(value: String): String =
|
||||
if (stripHtml(value).isBlank() && !value.contains("<img", ignoreCase = true)) "" else value
|
||||
|
||||
private fun stripHtml(value: String): String = value
|
||||
.replace(Regex("<[^>]+>"), "")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
|
||||
private fun escapeHtml(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
// HTML helper functions moved to RichTextUtils.kt for reuse and testing
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
fun normalizeEmptyHtml(value: String): String =
|
||||
if (stripHtml(value).isBlank() && !value.contains("<img", ignoreCase = true)) "" else value
|
||||
|
||||
fun stripHtml(value: String): String = value
|
||||
.replace(Regex("<[^>]+>"), "")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
|
||||
fun escapeHtml(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
// Placeholder: functionality moved to CmsScreens.kt (CmsUserListPage / UserCard)
|
||||
@@ -0,0 +1,306 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var selection by remember { mutableStateOf(setOf<Int>()) }
|
||||
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||
val loginState by loginVm.state.collectAsState()
|
||||
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||
val context = LocalContext.current
|
||||
var showSuccessDialog by remember { mutableStateOf(false) }
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(state.message) {
|
||||
if (!state.message.isNullOrBlank()) showSuccessDialog = true
|
||||
}
|
||||
|
||||
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||
var dialogOpen by remember { mutableStateOf(false) }
|
||||
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
|
||||
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var content by remember { mutableStateOf("") }
|
||||
var isPublic by remember { mutableStateOf(false) }
|
||||
var isHidden by remember { mutableStateOf(false) }
|
||||
var expiresAt by remember { mutableStateOf("") } // format: yyyy-MM-dd'T'HH:mm
|
||||
|
||||
val dtFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
|
||||
val displayFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy, HH:mm", Locale.GERMANY)
|
||||
|
||||
fun convertUTCToLocal(utc: String?): String {
|
||||
if (utc.isNullOrBlank()) return ""
|
||||
return try {
|
||||
val instant = Instant.parse(utc)
|
||||
LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).format(dtFormatter)
|
||||
} catch (e: Exception) { "" }
|
||||
}
|
||||
|
||||
fun convertLocalToUTC(local: String?): String? {
|
||||
if (local.isNullOrBlank()) return null
|
||||
return try {
|
||||
val ldt = LocalDateTime.parse(local, dtFormatter)
|
||||
ldt.atZone(ZoneId.systemDefault()).toInstant().toString()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
// open create
|
||||
fun openAdd() {
|
||||
editing = null
|
||||
title = ""
|
||||
content = ""
|
||||
isPublic = false
|
||||
isHidden = false
|
||||
expiresAt = ""
|
||||
dialogOpen = true
|
||||
}
|
||||
|
||||
// open edit
|
||||
fun openEdit(item: NewsDto) {
|
||||
editing = item
|
||||
title = item.title
|
||||
content = item.content
|
||||
isPublic = item.isPublic
|
||||
isHidden = item.isHidden
|
||||
expiresAt = convertUTCToLocal(item.expiresAt)
|
||||
dialogOpen = true
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||
if (state.loading) item { CircularProgressIndicator() }
|
||||
|
||||
item {
|
||||
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||
}
|
||||
|
||||
item {
|
||||
if (canWrite) Button(onClick = { openAdd() }, modifier = Modifier.fillMaxWidth()) { Text("News erstellen") }
|
||||
}
|
||||
|
||||
item {
|
||||
FormMessages(state.error, state.message)
|
||||
}
|
||||
|
||||
if (!state.loading && state.news.isEmpty()) item { Text("Noch keine News vorhanden.", modifier = Modifier.padding(12.dp)) }
|
||||
|
||||
// selection state for bulk actions (moved to outer scope)
|
||||
|
||||
items(state.news) { news ->
|
||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
||||
id?.let {
|
||||
selection = if (sel) selection + it else selection - it
|
||||
}
|
||||
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
||||
}
|
||||
|
||||
// bulk action bar
|
||||
if (selection.isNotEmpty()) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), false) }) { Text("Als nicht-öffentlich markieren") }
|
||||
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), true) }) { Text("Ausblenden") }
|
||||
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), false) }) { Text("Einblenden") }
|
||||
Button(onClick = { /* confirm then delete */ deletingIds = selection.toList() }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (moved earlier)
|
||||
|
||||
// delete confirmation dialog
|
||||
if (deletingIds != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { deletingIds = null },
|
||||
title = { Text("News löschen") },
|
||||
text = { Text("Möchten Sie die ausgewählten News wirklich löschen?") },
|
||||
confirmButton = { Button(onClick = {
|
||||
deletingIds?.let { viewModel.bulkDelete(it) }
|
||||
deletingIds = null
|
||||
selection = emptySet()
|
||||
}) { Text("Löschen") } },
|
||||
dismissButton = { TextButton(onClick = { deletingIds = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
// dialog for create/edit
|
||||
if (dialogOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogOpen = false },
|
||||
title = { Text(if (editing == null) "News erstellen" else "News bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Titel *") }, modifier = Modifier.fillMaxWidth())
|
||||
NativeRichTextEditor(content, { content = it }, "Inhalt *")
|
||||
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it })
|
||||
Text("Öffentliche News (auf Startseite anzeigen)", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
// read-only datetime field that opens native pickers
|
||||
OutlinedTextField(
|
||||
value = expiresAt,
|
||||
onValueChange = { /* no-op: controlled by pickers */ },
|
||||
label = { Text("Ablaufdatum (optional)") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
// open date then time picker
|
||||
val now = java.util.Calendar.getInstance()
|
||||
val year = now.get(java.util.Calendar.YEAR)
|
||||
val month = now.get(java.util.Calendar.MONTH)
|
||||
val day = now.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
android.app.DatePickerDialog(context, { _, y, m, d ->
|
||||
val hour = now.get(java.util.Calendar.HOUR_OF_DAY)
|
||||
val minute = now.get(java.util.Calendar.MINUTE)
|
||||
android.app.TimePickerDialog(context, { _, h, min ->
|
||||
val ldt = LocalDateTime.of(y, m + 1, d, h, min)
|
||||
expiresAt = ldt.format(dtFormatter)
|
||||
}, hour, minute, true).show()
|
||||
}, year, month, day).show()
|
||||
},
|
||||
readOnly = true,
|
||||
)
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = isHidden, onCheckedChange = { isHidden = it })
|
||||
Text("News ausblenden", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
val err = state.error
|
||||
if (err != null) {
|
||||
Text(err, color = Color(0xFF842029))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
val req = NewsSaveRequest(
|
||||
id = editing?.id,
|
||||
title = title,
|
||||
content = content,
|
||||
isPublic = isPublic,
|
||||
isHidden = isHidden,
|
||||
expiresAt = convertLocalToUTC(expiresAt),
|
||||
)
|
||||
viewModel.saveNews(req)
|
||||
dialogOpen = false
|
||||
}, enabled = !state.saving) { Text(if (state.saving) "Speichert..." else "Speichern") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dialogOpen = false }) { Text("Abbrechen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSuccessDialog && !state.message.isNullOrBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSuccessDialog = false },
|
||||
title = { Text("Erfolg") },
|
||||
text = { Text(state.message ?: "") },
|
||||
confirmButton = { Button(onClick = { showSuccessDialog = false }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsListItem(
|
||||
news: NewsDto,
|
||||
selected: Boolean = false,
|
||||
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
|
||||
onEdit: (NewsDto) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||
if (news.isPublic) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF0EA5A6)))
|
||||
Text("Öffentlich", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
if (news.isHidden) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color.Gray))
|
||||
Text("Ausgeblendet", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
val expired = news.expiresAt?.let {
|
||||
try { Instant.parse(it).isBefore(Instant.now()) || Instant.parse(it).equals(Instant.now()) } catch (e: Exception) { false }
|
||||
} ?: false
|
||||
if (expired) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFB91C1C)))
|
||||
Text("Abgelaufen", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(news.author ?: "-", modifier = Modifier.padding(end = 12.dp))
|
||||
Text(news.created ?: "-")
|
||||
}
|
||||
if (news.updated != null && news.updated != news.created) {
|
||||
Text("Aktualisiert: ${news.updated}")
|
||||
}
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ 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
|
||||
import de.harheimertc.BuildConfig
|
||||
@@ -72,6 +73,8 @@ fun HomeScreen(
|
||||
showNavigationHeader: Boolean = true,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||
|
||||
@@ -97,6 +100,7 @@ fun HomeScreen(
|
||||
AppNavigationHeader(
|
||||
selectedRoute = Destinations.Home.route,
|
||||
onNavigate = navController::navigate,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.harheimertc.ui.util
|
||||
|
||||
import java.net.UnknownHostException
|
||||
|
||||
object ErrorMapper {
|
||||
fun mapError(t: Throwable?): String? {
|
||||
if (t == null) return null
|
||||
return when (t) {
|
||||
is UnknownHostException -> "Server nicht erreichbar. Prüfe Netzwerkverbindung."
|
||||
else -> {
|
||||
val msg = t.message
|
||||
when {
|
||||
msg == null -> "Unbekannter Fehler"
|
||||
msg.contains("401") || msg.contains("Unauthorized", ignoreCase = true) -> "Nicht autorisiert"
|
||||
msg.contains("timeout", ignoreCase = true) -> "Zeitüberschreitung beim Server"
|
||||
else -> msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
<resources>
|
||||
<string name="app_name">Harheimer TC</string>
|
||||
<string name="gallery_title">Photo gallery</string>
|
||||
<string name="gallery_empty">There are no images in the gallery yet.</string>
|
||||
<string name="gallery_upload_title">Upload image</string>
|
||||
<string name="gallery_upload_show">Open</string>
|
||||
<string name="gallery_upload_hide">Close</string>
|
||||
<string name="gallery_upload_choose_file">Select image file</string>
|
||||
<string name="gallery_upload_image_title">Title</string>
|
||||
<string name="gallery_upload_description">Description (optional)</string>
|
||||
<string name="gallery_upload_public">Publicly visible</string>
|
||||
<string name="gallery_upload_submit">Upload image</string>
|
||||
<string name="gallery_uploading">Uploading...</string>
|
||||
<string name="gallery_close_image">Close image</string>
|
||||
<string name="gallery_image_description">Gallery image: %1$s</string>
|
||||
<string name="gallery_title">Bildergalerie</string>
|
||||
<string name="gallery_empty">Noch keine Bilder in der Galerie.</string>
|
||||
<string name="gallery_upload_title">Bild hochladen</string>
|
||||
<string name="gallery_upload_show">Öffnen</string>
|
||||
<string name="gallery_upload_hide">Schließen</string>
|
||||
<string name="gallery_upload_choose_file">Bilddatei auswählen</string>
|
||||
<string name="gallery_upload_image_title">Titel</string>
|
||||
<string name="gallery_upload_description">Beschreibung (optional)</string>
|
||||
<string name="gallery_upload_public">Öffentlich sichtbar</string>
|
||||
<string name="gallery_upload_submit">Bild hochladen</string>
|
||||
<string name="gallery_uploading">Wird hochgeladen...</string>
|
||||
<string name="gallery_close_image">Bild schließen</string>
|
||||
<string name="gallery_image_description">Galeriebild: %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class RichTextUtilsTest {
|
||||
@Test
|
||||
fun stripHtml_removesTagsAndEntities() {
|
||||
val html = "<p><strong>Hallo</strong> Welt</p>"
|
||||
val stripped = stripHtml(html)
|
||||
assertEquals("Hallo Welt", stripped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizeEmptyHtml_returnsEmptyForBlankContent() {
|
||||
val html = "<p><br></p>"
|
||||
val normalized = normalizeEmptyHtml(html)
|
||||
assertEquals("", normalized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapeHtml_escapesSpecialChars() {
|
||||
val raw = "https://example.com/?q=1&name=\"x\""
|
||||
val escaped = escapeHtml(raw)
|
||||
assertEquals("https://example.com/?q=1&name="x"", escaped)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class CmsViewModelTest {
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C"))))
|
||||
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
@@ -59,8 +60,10 @@ class CmsViewModelTest {
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
|
||||
val vm = CmsViewModel(repo)
|
||||
|
||||
// wait for init/load to finish before saving to avoid race
|
||||
@@ -73,5 +76,109 @@ class CmsViewModelTest {
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("Inhalt gespeichert.", state.message)
|
||||
assertEquals("X", state.config?.website?.verantwortlicher?.vorname)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveNews_success_updatesState() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse())
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c"))
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("saved", state.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateUserRoles_updatesUsersAndMessage() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("mitglied")))))
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
|
||||
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.updateUserRoles("1", listOf("admin", "vorstand"))
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("roles updated", state.message)
|
||||
assertEquals(listOf("admin", "vorstand"), state.users.first().roles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setUserActive_updatesUsersAndMessage() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = true))))
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
|
||||
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.setUserActive("2", false)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("user updated", state.message)
|
||||
assertEquals(false, state.users.first().active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resendInvite_setsMessageOnSuccess() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse())
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
|
||||
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.resendInvite("10")
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("invite sent", state.message)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,5 @@
|
||||
# Using AGP 9.2.1 defaults
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
# Local API base URL for running the app from Android Studio / Gradle
|
||||
LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
|
||||
Reference in New Issue
Block a user