Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -131,8 +131,12 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
[x] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
[x] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||||
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
|
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
|
||||||
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
|
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
|
||||||
- [ ] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
|
- [x] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
|
||||||
- [ ] Compose-UI-Tests für kritische Screens ergänzen
|
- [ ] Compose-UI-Tests für kritische Screens ergänzen
|
||||||
|
- [x] Hilt androidTest dependencies und `kspAndroidTest` konfiguriert
|
||||||
|
- [x] `HiltTestApplication` in `androidTest`-Manifest gesetzt
|
||||||
|
- [x] `LoginScreenTest` zu `@HiltAndroidTest` migriert und `HiltAndroidRule` hinzugefügt
|
||||||
|
- [x] `TestHiltModules.kt` für androidTest hinzugefügt (Test‑Bindings bereitgestellt)
|
||||||
[x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
[x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
||||||
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
||||||
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||||
@@ -196,6 +200,27 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||||
|
|
||||||
|
8a) Aktueller Teststatus & Troubleshooting (Stand: 2026-05-28)
|
||||||
|
|
||||||
|
- **Status:** `:app:assembleAndroidTest` läuft durch; `:app:connectedAndroidTest` ist derzeit instabil und schlägt bei Instrumentation-Läufen fehl.
|
||||||
|
- **Beobachtete Probleme:**
|
||||||
|
- Kompilationsfehler in `LoginScreenTest.kt` wegen `HiltTestActivity` (Unresolved reference). Workaround: `createAndroidComposeRule<ComponentActivity>()` + `setContent{}` verwenden, damit `assembleAndroidTest` durchläuft.
|
||||||
|
- Laufzeit-/Device-Probleme bei `connectedAndroidTest`: `com.android.ddmlib.SyncException: Remote object doesn't exist!` und `DELETE_FAILED_INTERNAL_ERROR` beim Deinstallieren von Test-APKs.
|
||||||
|
- `AndroidTestLogcatPlugin` wirft `FileNotFoundException` für erwartete Log-/Crash-Dateien, weil Gradle/UTP manche Device-Artefakte nicht zuverlässig pulled.
|
||||||
|
- Einzelne Instrumentation-Tests (z. B. `CmsActivateResendTest`, `GalleryScreenTest`) zeigen Assertion-Fehlschläge — diese sollten isoliert reproduziert werden.
|
||||||
|
- **Kurzfristige Empfehlungen (nicht ausführen):**
|
||||||
|
- Emulator neu starten und sicherstellen, dass keine veralteten Test-APKs installiert sind.
|
||||||
|
- Manuell: `adb uninstall` der Test-Pakete, dann frisches `adb install -r` des Test-APKs und gezielter Einzeltest via:
|
||||||
|
|
||||||
|
`adb shell am instrument -w -e class <test-class>#<testMethod> de.harheimertc.test/androidx.test.runner.AndroidJUnitRunner`
|
||||||
|
|
||||||
|
parallel `adb logcat -v time > /tmp/harheimertc_live_logcat.txt` laufen lassen, um vollständige Logs zu speichern.
|
||||||
|
- Falls UTP/ddmlib `SyncException` weiter auftritt: Gradle-Parallelität reduzieren, Test-Plugins (z. B. `AndroidTestLogcatPlugin`) temporär deaktivieren oder Tests in kleinere Gruppen splitten.
|
||||||
|
- **Offene Test‑To‑Dos:**
|
||||||
|
- Reproduzierbaren Einzeltest-Run mit vollständigem `logcat` erfassen (derzeit vom Nutzer pausiert).
|
||||||
|
- Flaky Tests isolieren und Hilt/KSP-Setup prüfen, damit `HiltTestActivity`-Importe nicht mehr fehlschlagen.
|
||||||
|
- Langfristig: Tests aufteilen, flaky tests markieren und CI-Job für androidTests gegen UTP-Transient-Fehler härten.
|
||||||
|
|
||||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||||
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
|
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
|
||||||
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||||
@@ -224,3 +249,96 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
|
|
||||||
---
|
---
|
||||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||||
|
|
||||||
|
**CMS-Verbesserungsplan (Analyse → Umsetzung)**
|
||||||
|
|
||||||
|
Ziel: Alle `cms/*`-Screens von rudimentärem Status zu vollständigen, getesteten Admin-Tools weiterentwickeln. Fokus: Datenintegrität, Berechtigungen, bessere UI/UX, Offline-Verhalten und Tests.
|
||||||
|
|
||||||
|
Kurzüberblick (3 Phasen):
|
||||||
|
- Phase A — Analyse (1-2 Tage): Inventar aller CMS-Endpunkte, fehlende CRUD-Workflows identifizieren, Prioritäten setzen (News, Benutzer, Kontaktanfragen, Newsletter, Config). Ergebnis: Aufgabenliste mit Aufwandsschätzung.
|
||||||
|
- Phase B — Implementierung MVP (1-2 Wochen): Kernfunktionen pro Bereich implementieren (News CRUD mit RichText-Vorschau, Benutzerliste + Rollen-Edit, Kontaktanfragen Detail & Antwort-Workflow, Newsletter-Gruppen-Management, Config-Editor inklusive Satzung-PDF-Feld). Unit- / Integrationstests für ViewModels.
|
||||||
|
- Phase C — Harden, UX & Tests (1 Woche): Validierung, Fehlermeldungen, Offline-Caching (verschlüsselt für geschützte Daten), Compose-UI-Tests, Accessibility-, Performance-Feinschliff.
|
||||||
|
|
||||||
|
Detaillierte Aufgaben (priorisiert):
|
||||||
|
- A1: Audit `CmsViewModel`-State vs. Backend-Responses — fehlen Felder/Fehlerfälle? (bereits teilweise umgesetzt)
|
||||||
|
- A2: Prüfen, ob API-Fehler (4xx/5xx) sauber an `FormMessages`/UI gemeldet werden — Standardisiere Fehlermeldungen.
|
||||||
|
- A3: Prüfen, ob `NativeRichTextEditor` HTML speichert, das Web-Editor-kompatibel bleibt (Quill/HTML). Schreibe Roundtrip-Tests.
|
||||||
|
- B1: News-Management
|
||||||
|
- B1.1: News-CRUD: Create/Update/Delete mit Vorschau (RichText-Preview) und Validierung (Titel Pflicht, Inhalt Mindestlänge)
|
||||||
|
- B1.2: Bulk-Aktionen: Sichtbar/Unsichtbar/ExpiresAt setzen
|
||||||
|
- B1.3: Unit-Tests für `NewsViewModel` + `CmsViewModel`-Integrationspfad
|
||||||
|
- B2: Benutzer-Management
|
||||||
|
- B2.1: Rollen-Edit (admin/vorstand/trainer/newsletter) in `CmsBenutzerScreen` (Inline-Action oder Detail-Dialog)
|
||||||
|
- B2.2: Aktiv/Inaktiv Toggle + Resend-Invite (falls API unterstützt)
|
||||||
|
- B2.3: Tests: `CmsViewModel.users()` Verhalten bei Pagination/Leeren Listen
|
||||||
|
- B3: Kontaktanfragen
|
||||||
|
- B3.1: Detailansicht mit Antwort-Option (falls Backend Mail-Sende-Endpunkt vorhanden)
|
||||||
|
- B3.2: Status-Filter (offen/beantwortet) und Bulk-Archiv
|
||||||
|
- B4: Newsletter
|
||||||
|
- B4.1: Entwurf -> Senden Flow mit Preview (falls Backend zulässt)
|
||||||
|
- B4.2: Gruppenverwaltung (CRUD) + Subscribe/Unsubscribe-Preview
|
||||||
|
- B5: Config / Seiten (Inhalte)
|
||||||
|
- B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis
|
||||||
|
- B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert)
|
||||||
|
- B6: Diagnostics / Passwort-Reset-Diagnose
|
||||||
|
- B6.1: Detail-View mit exportierbaren Logs (bei Bedarf)
|
||||||
|
- C1: Offline-/Caching-Strategie
|
||||||
|
- C1.1: Verschlüsseltes lokales Caching für CMS-Daten (EncryptedSharedPreferences/Room)
|
||||||
|
- C1.2: Sync-Strategie: lokale Änderungen buffernd senden, Konflikt-UI
|
||||||
|
- C2: Tests & CI
|
||||||
|
- C2.1: ViewModel-Unit-Tests für alle CMS-Flows
|
||||||
|
- C2.2: Compose-UI-Tests für kritische Pfade (News erstellen, Benutzerrolle ändern, Config speichern)
|
||||||
|
- C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||||
|
|
||||||
|
Minor UX-Verbesserungen (parallel möglich):
|
||||||
|
- konsistente Buttons/Labels (`Speichern` vs `Inhalt speichern`), Ladezustand-UI, einzeilige Success-/Error-Banner, Inline-Validierungen.
|
||||||
|
|
||||||
|
Deliverables & Milestones:
|
||||||
|
- M1 (nach Analyse): Priorisierte Aufgabenliste + Schätzung (mehrere PRs)
|
||||||
|
- M2 (nach MVP-Implementierung): News + Benutzer + ContactRequests + Config Editor + Tests (smoke)
|
||||||
|
- M3 (Final): Offline, UI-Tests, Accessibility, Performance
|
||||||
|
|
||||||
|
Zeitplanung (empfohlen):
|
||||||
|
- Analyse: 2 Arbeitstage
|
||||||
|
- MVP-Implementierung: 7–10 Arbeitstage
|
||||||
|
- Hardening + Tests: 3–5 Arbeitstage
|
||||||
|
|
||||||
|
Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Tracker (oder als separate TODOs) ein und beginne mit A1/A2.
|
||||||
|
|
||||||
|
**TODO (zum Abhaken) — CMS-Implementierung**
|
||||||
|
|
||||||
|
- [x] A1: Audit `CmsViewModel` vs Backend-Responses (Fehleraggregation implementiert)
|
||||||
|
- [x] A2: Standardisiere API-Fehlerdarstellung in UI (`FormMessages` / globale Errors)
|
||||||
|
- [x] A3: Roundtrip-Tests `NativeRichTextEditor` ↔ Backend-HTML (Kompatibilität / Quill)
|
||||||
|
- [x] B1: News-Management
|
||||||
|
- [x] B1.1: News-CRUD (Create/Update/Delete) mit RichText-Vorschau
|
||||||
|
- [x] B1.2: Bulk-Aktionen (sichtbar/unsichtbar, expiresAt)
|
||||||
|
- [x] B1.3: Unit-Tests für `NewsViewModel`
|
||||||
|
|
||||||
|
- [x] B2: Benutzer-Management
|
||||||
|
- [x] B2.1: Rollen-Edit (Inline oder Detail-Dialog)
|
||||||
|
- [x] B2.2: Aktiv/Inaktiv Toggle, Resend-Invite
|
||||||
|
- [x] B2.3: Tests für Pagination/Leere Listen
|
||||||
|
|
||||||
|
- [x] B3: Kontaktanfragen
|
||||||
|
- [x] B3.1: Detailansicht + Antwort-Option
|
||||||
|
- [x] B3.2: Status-Filter + Archiv
|
||||||
|
|
||||||
|
- [ ] B4: Newsletter
|
||||||
|
- [ ] B4.1: Entwurf → Senden Flow mit Preview
|
||||||
|
- [ ] B4.2: Gruppenverwaltung (CRUD)
|
||||||
|
|
||||||
|
- [ ] B5: Config / Seiten
|
||||||
|
- [ ] B5.1: Sichern/Zurücksetzen mit Undo
|
||||||
|
- [ ] B5.2: Satzung: PDF-Upload-Feld + native PDF-Viewer
|
||||||
|
|
||||||
|
- [ ] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
|
||||||
|
|
||||||
|
- [ ] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
|
||||||
|
- [ ] C2: Tests & CI
|
||||||
|
- [ ] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
|
||||||
|
- [ ] C2.2: Compose-UI-Tests für kritische Flows
|
||||||
|
- [ ] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||||
|
|
||||||
|
Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||||
.orElse("http://10.0.2.2:3100/")
|
.orElse("https://harheimertc.tsschulz.de/")
|
||||||
.get()
|
.get()
|
||||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||||
.orElse("")
|
.orElse("")
|
||||||
@@ -46,7 +46,7 @@ android {
|
|||||||
}
|
}
|
||||||
create("production") {
|
create("production") {
|
||||||
dimension = "environment"
|
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", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
@@ -131,4 +131,14 @@ dependencies {
|
|||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
testImplementation("io.mockk:mockk:1.13.7")
|
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.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Multipart
|
import retrofit2.http.Multipart
|
||||||
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Part
|
import retrofit2.http.Part
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.PUT
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
@@ -17,7 +19,7 @@ import okhttp3.ResponseBody
|
|||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
|
|
||||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
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 TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
data class TerminDto(
|
data class TerminDto(
|
||||||
val datum: String = "",
|
val datum: String = "",
|
||||||
@@ -557,9 +559,29 @@ interface ApiService {
|
|||||||
@GET("/api/cms/users/list")
|
@GET("/api/cms/users/list")
|
||||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
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")
|
@GET("/api/cms/contact-requests")
|
||||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
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")
|
@GET("/api/newsletter/list")
|
||||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ object NetworkModule {
|
|||||||
cache: Cache,
|
cache: Cache,
|
||||||
): OkHttpClient {
|
): OkHttpClient {
|
||||||
val logging = HttpLoggingInterceptor()
|
val logging = HttpLoggingInterceptor()
|
||||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
|
||||||
val cookies = CookieManager().apply {
|
val cookies = CookieManager().apply {
|
||||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,10 @@ object NetworkModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
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()
|
return Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(runtimeBase)
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ class CmsRepository @Inject constructor(
|
|||||||
fallbackMessage = "Benutzer konnten nicht geladen werden.",
|
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>> =
|
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -56,6 +76,19 @@ class CmsRepository @Inject constructor(
|
|||||||
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
|
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> =
|
suspend fun newsletters(): Result<NewsletterListResponse> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -92,6 +125,30 @@ class CmsRepository @Inject constructor(
|
|||||||
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
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(
|
private suspend fun <T> fetchEncryptedFallback(
|
||||||
load: suspend () -> T,
|
load: suspend () -> T,
|
||||||
save: (T) -> Unit,
|
save: (T) -> Unit,
|
||||||
|
|||||||
@@ -40,8 +40,19 @@ class MemberAreaRepository @Inject constructor(
|
|||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
val response = api.memberNews()
|
val response = api.memberNews()
|
||||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
if (!response.isSuccessful) {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
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,
|
save = cache::putNews,
|
||||||
cached = cache::getNews,
|
cached = cache::getNews,
|
||||||
|
|||||||
@@ -202,16 +202,4 @@ private fun selectedText(value: TextFieldValue): String {
|
|||||||
return value.text.substring(start, end)
|
return value.text.substring(start, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeEmptyHtml(value: String): String =
|
// HTML helper functions moved to RichTextUtils.kt for reuse and testing
|
||||||
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("\"", """)
|
|
||||||
|
|||||||
@@ -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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
@@ -72,6 +73,8 @@ fun HomeScreen(
|
|||||||
showNavigationHeader: Boolean = true,
|
showNavigationHeader: Boolean = true,
|
||||||
viewModel: HomeViewModel = hiltViewModel(),
|
viewModel: HomeViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||||
|
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) }
|
||||||
|
|
||||||
@@ -97,6 +100,7 @@ fun HomeScreen(
|
|||||||
AppNavigationHeader(
|
AppNavigationHeader(
|
||||||
selectedRoute = Destinations.Home.route,
|
selectedRoute = Destinations.Home.route,
|
||||||
onNavigate = navController::navigate,
|
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>
|
<resources>
|
||||||
<string name="app_name">Harheimer TC</string>
|
<string name="app_name">Harheimer TC</string>
|
||||||
<string name="gallery_title">Photo gallery</string>
|
<string name="gallery_title">Bildergalerie</string>
|
||||||
<string name="gallery_empty">There are no images in the gallery yet.</string>
|
<string name="gallery_empty">Noch keine Bilder in der Galerie.</string>
|
||||||
<string name="gallery_upload_title">Upload image</string>
|
<string name="gallery_upload_title">Bild hochladen</string>
|
||||||
<string name="gallery_upload_show">Open</string>
|
<string name="gallery_upload_show">Öffnen</string>
|
||||||
<string name="gallery_upload_hide">Close</string>
|
<string name="gallery_upload_hide">Schließen</string>
|
||||||
<string name="gallery_upload_choose_file">Select image file</string>
|
<string name="gallery_upload_choose_file">Bilddatei auswählen</string>
|
||||||
<string name="gallery_upload_image_title">Title</string>
|
<string name="gallery_upload_image_title">Titel</string>
|
||||||
<string name="gallery_upload_description">Description (optional)</string>
|
<string name="gallery_upload_description">Beschreibung (optional)</string>
|
||||||
<string name="gallery_upload_public">Publicly visible</string>
|
<string name="gallery_upload_public">Öffentlich sichtbar</string>
|
||||||
<string name="gallery_upload_submit">Upload image</string>
|
<string name="gallery_upload_submit">Bild hochladen</string>
|
||||||
<string name="gallery_uploading">Uploading...</string>
|
<string name="gallery_uploading">Wird hochgeladen...</string>
|
||||||
<string name="gallery_close_image">Close image</string>
|
<string name="gallery_close_image">Bild schließen</string>
|
||||||
<string name="gallery_image_description">Gallery image: %1$s</string>
|
<string name="gallery_image_description">Galeriebild: %1$s</string>
|
||||||
</resources>
|
</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.contactRequests() } returns Result.success(emptyList())
|
||||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
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())
|
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||||
|
|
||||||
val vm = CmsViewModel(repo)
|
val vm = CmsViewModel(repo)
|
||||||
@@ -59,8 +60,10 @@ class CmsViewModelTest {
|
|||||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
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.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||||
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
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)
|
val vm = CmsViewModel(repo)
|
||||||
|
|
||||||
// wait for init/load to finish before saving to avoid race
|
// wait for init/load to finish before saving to avoid race
|
||||||
@@ -73,5 +76,109 @@ class CmsViewModelTest {
|
|||||||
assertEquals(false, state.saving)
|
assertEquals(false, state.saving)
|
||||||
assertEquals("Inhalt gespeichert.", state.message)
|
assertEquals("Inhalt gespeichert.", state.message)
|
||||||
assertEquals("X", state.config?.website?.verantwortlicher?.vorname)
|
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
|
# Using AGP 9.2.1 defaults
|
||||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||||
org.gradle.workers.max=2
|
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/
|
||||||
|
|||||||
296
package-lock.json
generated
296
package-lock.json
generated
@@ -1,18 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.4.5",
|
"version": "1.6.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.4.5",
|
"version": "1.6.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tinymce/tinymce-vue": "^6.3.0",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"quill": "^2.0.2",
|
"quill": "^2.0.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tinymce": "^8.3.1",
|
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2795,76 +2793,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/config-array": {
|
|
||||||
"version": "0.23.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
|
|
||||||
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@eslint/object-schema": "^3.0.5",
|
|
||||||
"debug": "^4.3.1",
|
|
||||||
"minimatch": "^10.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/config-helpers": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@eslint/core": "^1.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/core": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@types/json-schema": "^7.0.15"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/object-schema": {
|
|
||||||
"version": "3.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
|
||||||
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/plugin-kit": {
|
|
||||||
"version": "0.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
|
||||||
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@eslint/core": "^1.2.1",
|
|
||||||
"levn": "^0.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/@nuxt/kit": {
|
"node_modules/@nuxt/vite-builder/node_modules/@nuxt/kit": {
|
||||||
"version": "4.4.6",
|
"version": "4.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.6.tgz",
|
||||||
@@ -2896,31 +2824,6 @@
|
|||||||
"node": ">=18.12.0"
|
"node": ">=18.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/chokidar": {
|
"node_modules/@nuxt/vite-builder/node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -2936,172 +2839,6 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/eslint": {
|
|
||||||
"version": "10.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
|
|
||||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
|
||||||
"@eslint/config-array": "^0.23.5",
|
|
||||||
"@eslint/config-helpers": "^0.6.0",
|
|
||||||
"@eslint/core": "^1.2.1",
|
|
||||||
"@eslint/plugin-kit": "^0.7.1",
|
|
||||||
"@humanfs/node": "^0.16.6",
|
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
|
||||||
"@humanwhocodes/retry": "^0.4.2",
|
|
||||||
"@types/estree": "^1.0.6",
|
|
||||||
"ajv": "^6.14.0",
|
|
||||||
"cross-spawn": "^7.0.6",
|
|
||||||
"debug": "^4.3.2",
|
|
||||||
"escape-string-regexp": "^4.0.0",
|
|
||||||
"eslint-scope": "^9.1.2",
|
|
||||||
"eslint-visitor-keys": "^5.0.1",
|
|
||||||
"espree": "^11.2.0",
|
|
||||||
"esquery": "^1.7.0",
|
|
||||||
"esutils": "^2.0.2",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"file-entry-cache": "^8.0.0",
|
|
||||||
"find-up": "^5.0.0",
|
|
||||||
"glob-parent": "^6.0.2",
|
|
||||||
"ignore": "^5.2.0",
|
|
||||||
"imurmurhash": "^0.1.4",
|
|
||||||
"is-glob": "^4.0.0",
|
|
||||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
|
||||||
"minimatch": "^10.2.4",
|
|
||||||
"natural-compare": "^1.4.0",
|
|
||||||
"optionator": "^0.9.3"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"eslint": "bin/eslint.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://eslint.org/donate"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"jiti": "*"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"jiti": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/eslint-scope": {
|
|
||||||
"version": "9.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
|
||||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@types/esrecurse": "^4.3.1",
|
|
||||||
"@types/estree": "^1.0.8",
|
|
||||||
"esrecurse": "^4.3.0",
|
|
||||||
"estraverse": "^5.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/eslint"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/eslint-visitor-keys": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/eslint"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/eslint/node_modules/escape-string-regexp": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/eslint/node_modules/ignore": {
|
|
||||||
"version": "5.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
|
||||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/espree": {
|
|
||||||
"version": "11.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
|
||||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"acorn": "^8.16.0",
|
|
||||||
"acorn-jsx": "^5.3.2",
|
|
||||||
"eslint-visitor-keys": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/eslint"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/glob-parent": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/minimatch": {
|
|
||||||
"version": "10.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^5.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/vite-builder/node_modules/npm-run-path": {
|
"node_modules/@nuxt/vite-builder/node_modules/npm-run-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||||
@@ -5441,21 +5178,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tinymce/tinymce-vue": {
|
|
||||||
"version": "6.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-vue/-/tinymce-vue-6.3.0.tgz",
|
|
||||||
"integrity": "sha512-DSP8Jhd3XqCCliTnusfbmz3D8GqQ4iRzkc4aadYHDcJPVjkaqopJ61McOdH82CSy599vGLkPjGzqJYWJkRMiUA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1",
|
|
||||||
"vue": "^3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"tinymce": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@@ -5494,14 +5216,6 @@
|
|||||||
"@types/trusted-types": "*"
|
"@types/trusted-types": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
|
||||||
"version": "4.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
|
||||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -14246,12 +13960,6 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinymce": {
|
|
||||||
"version": "8.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.3.1.tgz",
|
|
||||||
"integrity": "sha512-mdQdTAA90aEIyhEteIwy+QQ6UnxPCd3qQ5MlGvvByOvnjyOSdBzBcmnXeqWuhGz3fIs3XBJjIw7JyIMiHjebqw==",
|
|
||||||
"license": "SEE LICENSE IN license.md"
|
|
||||||
},
|
|
||||||
"node_modules/tinyrainbow": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tinymce/tinymce-vue": "^6.3.0",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -40,7 +39,7 @@
|
|||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"quill": "^2.0.2",
|
"quill": "^2.0.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tinymce": "^8.3.1",
|
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
69
scripts/dev-android.sh
Executable file
69
scripts/dev-android.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT=3100
|
||||||
|
HOST_URL="http://127.0.0.1:${PORT}"
|
||||||
|
RETRIES=30
|
||||||
|
SLEEP_INTERVAL=1
|
||||||
|
|
||||||
|
print() { echo "[dev-android] $*"; }
|
||||||
|
|
||||||
|
# 1) Ensure dev server is running (try curl)
|
||||||
|
if curl -sSf "$HOST_URL/api/news-public" >/dev/null 2>&1; then
|
||||||
|
print "Dev server already responding at ${HOST_URL}"
|
||||||
|
else
|
||||||
|
print "Dev server not responding, starting 'npm run dev' in background..."
|
||||||
|
# Start dev server in a new session so we can keep this script interactive
|
||||||
|
(cd "$(dirname "$(realpath "$0")")/.." && nohup npm run dev -- --host 0.0.0.0 --port ${PORT} >/tmp/harheimertc-nuxt.log 2>&1 &) || true
|
||||||
|
print "Waiting for server to become ready (logs: /tmp/harheimertc-nuxt.log)"
|
||||||
|
i=0
|
||||||
|
until curl -sSf "$HOST_URL/api/news-public" >/dev/null 2>&1; do
|
||||||
|
i=$((i+1))
|
||||||
|
if [ $i -ge $RETRIES ]; then
|
||||||
|
print "Server did not become ready after ${RETRIES} attempts. Check /tmp/harheimertc-nuxt.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $SLEEP_INTERVAL
|
||||||
|
done
|
||||||
|
print "Server is ready"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) Wait for an adb device/emulator
|
||||||
|
print "Waiting for adb device/emulator (ctrl-c to abort)..."
|
||||||
|
while true; do
|
||||||
|
# list devices and skip header
|
||||||
|
devices=$(adb devices | sed '1d' | awk '{print $1 " " $2}' || true)
|
||||||
|
if echo "$devices" | grep -q "device"; then
|
||||||
|
print "Found device(s):"
|
||||||
|
echo "$devices"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3) Try adb reverse with retries
|
||||||
|
print "Setting adb reverse tcp:${PORT} -> tcp:${PORT}"
|
||||||
|
count=0
|
||||||
|
until adb reverse tcp:${PORT} tcp:${PORT}; do
|
||||||
|
count=$((count+1))
|
||||||
|
print "adb reverse failed (attempt ${count}). Retrying in 1s..."
|
||||||
|
if [ $count -ge 10 ]; then
|
||||||
|
print "adb reverse failed after ${count} attempts. Listing reverses and exiting with failure."
|
||||||
|
adb reverse --list || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
print "adb reverse configured:"
|
||||||
|
adb reverse --list || true
|
||||||
|
|
||||||
|
# 4) Verify from device
|
||||||
|
print "Verifying from device via 127.0.0.1:${PORT}"
|
||||||
|
if adb shell curl -sSf "http://127.0.0.1:${PORT}/api/news-public" >/dev/null 2>&1; then
|
||||||
|
print "Success: emulator/device can reach host dev server via 127.0.0.1:${PORT}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
print "Verification failed from device. Try 'adb logcat' or check firewall/VM network settings."
|
||||||
|
adb reverse --list || true
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
BIN
temp/harheimertc_gallery.png
Normal file
BIN
temp/harheimertc_gallery.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
temp/harheimertc_gallery2.png
Normal file
BIN
temp/harheimertc_gallery2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
BIN
temp/harheimertc_nav_check.png
Normal file
BIN
temp/harheimertc_nav_check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
temp/harheimertc_nav_check2.png
Normal file
BIN
temp/harheimertc_nav_check2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
1
temp/window_dump.xml
Normal file
1
temp/window_dump.xml
Normal file
File diff suppressed because one or more lines are too long
69
tmp/hilt-dex-search.txt
Normal file
69
tmp/hilt-dex-search.txt
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user