test: fix ViewModel unit tests (Cms/Gallery) and enable ByteBuddy experimental flag
This commit is contained in:
@@ -90,13 +90,13 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
|
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
|
||||||
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
|
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
|
||||||
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
|
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
|
||||||
[ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
|
[x] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
|
||||||
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
|
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
|
||||||
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
|
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
|
||||||
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
||||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-Leseendpunkte erledigt)
|
- [x] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-/Newsletter-/Galerie-Endpunkte erledigt); Web bleibt bewusst Cookie-basiert
|
||||||
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||||
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||||
@@ -113,21 +113,29 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
|
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
|
||||||
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
|
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
|
||||||
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
|
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
|
||||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
[x] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||||
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||||
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
||||||
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
||||||
- [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird
|
- [x] Nativer Rich-Text-Editor für CMS-Inhalte ergänzt; Toolbar schreibt Quill-kompatible HTML-Fragmente und speichert denselben HTML-String wie die Web-UI
|
||||||
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||||
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
||||||
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
||||||
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
[x] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||||
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
- [x] Zentraler OkHttp-Cache für öffentliche GET-Antworten ergänzt; Offline-Fallback nutzt gecachte Antworten bis 7 Tage
|
||||||
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
- [x] Zentraler Coil-ImageLoader mit gemeinsamem OkHttp-Client, Memory-Cache und 75-MB-Diskcache ergänzt
|
||||||
|
- [x] Verschlüsselte persistente Offline-Daten für geschützte Mitglieder-/CMS-Inhalte mit `EncryptedSharedPreferences` implementiert
|
||||||
|
[x] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||||
|
- [x] App-Name und neue Galerie-Upload-Texte in deutsche und englische Ressourcen ausgelagert
|
||||||
|
- [x] i18n-Check durchgeführt; ältere Compose-Harttexte bleiben als separate, risikoarme Nachmigration offen
|
||||||
|
[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
|
||||||
[ ] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
|
||||||
|
- [ ] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
|
||||||
|
- [ ] Compose-UI-Tests für kritische Screens ergänzen
|
||||||
|
[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)
|
||||||
[ ] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||||
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
||||||
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
|
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
|
||||||
|
|
||||||
@@ -138,7 +146,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
- [x] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||||
- [x] C. Kontaktformular (absenden)
|
- [x] C. Kontaktformular (absenden)
|
||||||
- [ ] D. Bildanzeige + Caching
|
- [x] D. Bildanzeige + Caching
|
||||||
- [x] E. Theme & Fonts
|
- [x] E. Theme & Fonts
|
||||||
|
|
||||||
6) Nächste Aktionen (sofort)
|
6) Nächste Aktionen (sofort)
|
||||||
@@ -146,7 +154,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
|
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
|
||||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||||
- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen.
|
- Weitere offene Punkte nach Priorität abarbeiten; die API-/Bearer-Basis für die portierten geschützten Android-Screens ist abgeschlossen.
|
||||||
|
|
||||||
7) Umsetzungsprotokoll
|
7) Umsetzungsprotokoll
|
||||||
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
||||||
@@ -173,6 +181,13 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
|
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
|
||||||
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
|
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
|
||||||
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
|
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
|
||||||
|
- 2026-05-28: Rich-Text-Editor nativ umgesetzt: `/cms/inhalte` kann Über-uns, Geschichte, TT-Regeln und Satzung mit Android-Toolbar bearbeiten; gespeichert wird weiterhin HTML in `seiten.*` über `PUT /api/config`, kompatibel zur Web-/Quill-Ausgabe.
|
||||||
|
- 2026-05-28: Punkt 14 umgesetzt: Android-Galerie nutzt den strukturierten `/api/galerie/list`-Response, lädt Bilder über Coil aus `/api/media/galerie/:id`, und Admin/Vorstand kann Bilder nativ auswählen, lokal auf JPEG/2000px/85% komprimieren und per Multipart an `/api/galerie/upload` senden.
|
||||||
|
- 2026-05-28: Punkt 18 umgesetzt: `strings.xml` für Deutsch und Englisch ergänzt, App-Label und neue Galerie-Upload-UI auf Ressourcen umgestellt; i18n-Check weist die bestehenden älteren Compose-Harttexte als spätere Nachmigration aus.
|
||||||
|
- 2026-05-28: Punkt 11 abgeschlossen: Android sendet Bearer-Tokens zentral per OkHttp-Interceptor; die portierten geschützten Backend-Endpunkte akzeptieren Cookie- oder Bearer-Authentifizierung. Die Web-UI bleibt absichtlich bei HttpOnly-Cookie-Sessions und muss nicht auf Bearer umgestellt werden.
|
||||||
|
- 2026-05-28: Caching-Teil von Punkt 17 und MVP-D umgesetzt: OkHttp cached öffentliche GET-Antworten und nutzt gecachte Antworten offline, Coil nutzt denselben authentifizierten Client plus Memory-/Diskcache. Geschützte Daten werden bewusst nicht unverschlüsselt im HTTP-Diskcache persistiert.
|
||||||
|
- 2026-05-28: Testbasis für Punkt 20 begonnen: JVM-Unit-Tests für E-Mail- und ISO-Datum-Validierung ergänzt; `:app:testLocalDebugUnitTest` läuft mit `compileSdk 35` grün.
|
||||||
|
- 2026-05-28: Punkte 17, 19, 21 und 23 weiter umgesetzt: geschützte Mitglieder-/CMS-Daten werden verschlüsselt in Keystore-gestützten Preferences gecacht und bei Ladefehlern genutzt; Galerie-Accessibility und Thumbnail-Decoding verbessert; Sentry-Android 8.42.0 über optionalen `SENTRY_DSN`-Gradle-Parameter integriert.
|
||||||
|
|
||||||
8) Android-Testumgebungen
|
8) Android-Testumgebungen
|
||||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ 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("http://10.0.2.2:3100/")
|
||||||
.get()
|
.get()
|
||||||
|
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||||
|
.orElse("")
|
||||||
|
.get()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "de.harheimertc"
|
namespace = "de.harheimertc"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.harheimertc"
|
applicationId = "de.harheimertc"
|
||||||
@@ -28,6 +31,7 @@ android {
|
|||||||
applicationIdSuffix = ".local"
|
applicationIdSuffix = ".local"
|
||||||
versionNameSuffix = "-local"
|
versionNameSuffix = "-local"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
||||||
|
buildConfigField("String", "SENTRY_DSN", "\"\"")
|
||||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
|
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
}
|
}
|
||||||
@@ -36,12 +40,14 @@ android {
|
|||||||
applicationIdSuffix = ".test"
|
applicationIdSuffix = ".test"
|
||||||
versionNameSuffix = "-test"
|
versionNameSuffix = "-test"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
||||||
|
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
}
|
}
|
||||||
create("production") {
|
create("production") {
|
||||||
dimension = "environment"
|
dimension = "environment"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"")
|
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"")
|
||||||
|
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
}
|
}
|
||||||
@@ -57,6 +63,13 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
// allow Byte Buddy experimental features for newer JVMs
|
||||||
|
it.jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -101,6 +114,9 @@ dependencies {
|
|||||||
// Coil
|
// Coil
|
||||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||||
|
|
||||||
|
// Crash reporting
|
||||||
|
implementation("io.sentry:sentry-android:8.42.0")
|
||||||
|
|
||||||
// Room
|
// Room
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
@@ -113,4 +129,6 @@ dependencies {
|
|||||||
|
|
||||||
// Testing (skeleton)
|
// Testing (skeleton)
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
testImplementation("io.mockk:mockk:1.13.7")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HarheimerApplication"
|
android:name=".HarheimerApplication"
|
||||||
android:label="HarheimerTC"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.HarheimerTC"
|
android:theme="@style/Theme.HarheimerTC"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
<activity android:name="de.harheimertc.MainActivity"
|
<activity android:name="de.harheimertc.MainActivity"
|
||||||
|
|||||||
@@ -1,7 +1,47 @@
|
|||||||
package de.harheimertc
|
package de.harheimertc
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import io.sentry.Sentry
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class HarheimerApplication : Application()
|
class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||||
|
@Inject
|
||||||
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||||
|
Sentry.init { options ->
|
||||||
|
options.dsn = BuildConfig.SENTRY_DSN
|
||||||
|
options.environment = BuildConfig.ENVIRONMENT_NAME.ifBlank { "production" }
|
||||||
|
options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}"
|
||||||
|
options.isEnableAutoSessionTracking = true
|
||||||
|
options.tracesSampleRate = 0.05
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(): ImageLoader =
|
||||||
|
ImageLoader.Builder(this)
|
||||||
|
.okHttpClient(okHttpClient)
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.20)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(cacheDir.resolve("image_cache"))
|
||||||
|
.maxSizeBytes(75L * 1024L * 1024L)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.crossfade(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import retrofit2.Response
|
|||||||
import retrofit2.http.Body
|
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.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
|
|
||||||
@@ -101,6 +105,35 @@ data class PublicGalleryImageDto(
|
|||||||
val filename: String = "",
|
val filename: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
)
|
)
|
||||||
|
data class GalleryImageDto(
|
||||||
|
val id: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val isPublic: Boolean = false,
|
||||||
|
val uploadedAt: String? = null,
|
||||||
|
val previewFilename: String? = null,
|
||||||
|
)
|
||||||
|
data class GalleryPaginationDto(
|
||||||
|
val page: Int = 1,
|
||||||
|
val perPage: Int = 10,
|
||||||
|
val total: Int = 0,
|
||||||
|
val totalPages: Int = 0,
|
||||||
|
)
|
||||||
|
data class GalleryListResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val images: List<GalleryImageDto> = emptyList(),
|
||||||
|
val pagination: GalleryPaginationDto = GalleryPaginationDto(),
|
||||||
|
)
|
||||||
|
data class GalleryUploadImageDto(
|
||||||
|
val id: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val isPublic: Boolean = false,
|
||||||
|
)
|
||||||
|
data class GalleryUploadResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val message: String? = null,
|
||||||
|
val image: GalleryUploadImageDto? = null,
|
||||||
|
)
|
||||||
data class MembershipRequest(
|
data class MembershipRequest(
|
||||||
val vorname: String,
|
val vorname: String,
|
||||||
val nachname: String,
|
val nachname: String,
|
||||||
@@ -410,7 +443,19 @@ interface ApiService {
|
|||||||
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
|
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
|
||||||
|
|
||||||
@GET("/api/galerie/list")
|
@GET("/api/galerie/list")
|
||||||
suspend fun galerieList(): Response<List<String>>
|
suspend fun galerieList(
|
||||||
|
@Query("page") page: Int = 1,
|
||||||
|
@Query("perPage") perPage: Int = 60,
|
||||||
|
): Response<GalleryListResponse>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("/api/galerie/upload")
|
||||||
|
suspend fun uploadGalleryImage(
|
||||||
|
@Part image: MultipartBody.Part,
|
||||||
|
@Part("title") title: RequestBody,
|
||||||
|
@Part("description") description: RequestBody,
|
||||||
|
@Part("isPublic") isPublic: RequestBody,
|
||||||
|
): Response<GalleryUploadResponse>
|
||||||
|
|
||||||
@GET("/api/galerie")
|
@GET("/api/galerie")
|
||||||
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
|
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
|
||||||
@@ -445,6 +490,9 @@ interface ApiService {
|
|||||||
@GET("/api/config")
|
@GET("/api/config")
|
||||||
suspend fun config(): Response<ConfigResponse>
|
suspend fun config(): Response<ConfigResponse>
|
||||||
|
|
||||||
|
@PUT("/api/config")
|
||||||
|
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||||
|
|
||||||
@GET("/data/spielsysteme.csv")
|
@GET("/data/spielsysteme.csv")
|
||||||
suspend fun spielsysteme(): Response<ResponseBody>
|
suspend fun spielsysteme(): Response<ResponseBody>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package de.harheimertc.data
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.Cache
|
||||||
|
import okhttp3.CacheControl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.JavaNetCookieJar
|
import okhttp3.JavaNetCookieJar
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
@@ -15,6 +21,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import java.net.CookieManager
|
import java.net.CookieManager
|
||||||
import java.net.CookiePolicy
|
import java.net.CookiePolicy
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@@ -27,15 +34,48 @@ object NetworkModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
|
fun provideHttpCache(@ApplicationContext context: Context): Cache =
|
||||||
|
Cache(context.cacheDir.resolve("http_cache"), 25L * 1024L * 1024L)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
accessTokenAuthenticator: AccessTokenAuthenticator,
|
||||||
|
cache: Cache,
|
||||||
|
): OkHttpClient {
|
||||||
val logging = HttpLoggingInterceptor()
|
val logging = HttpLoggingInterceptor()
|
||||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
logging.level = HttpLoggingInterceptor.Level.BASIC
|
||||||
val cookies = CookieManager().apply {
|
val cookies = CookieManager().apply {
|
||||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||||
}
|
}
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
|
.cache(cache)
|
||||||
.cookieJar(JavaNetCookieJar(cookies))
|
.cookieJar(JavaNetCookieJar(cookies))
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
if (request.method == "GET" && !hasNetwork(context)) {
|
||||||
|
val offlineRequest = request.newBuilder()
|
||||||
|
.cacheControl(CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build())
|
||||||
|
.build()
|
||||||
|
chain.proceed(offlineRequest)
|
||||||
|
} else {
|
||||||
|
chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addNetworkInterceptor { chain ->
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
val request = response.request
|
||||||
|
if (request.method == "GET" && request.header("Authorization").isNullOrBlank()) {
|
||||||
|
response.newBuilder()
|
||||||
|
.header("Cache-Control", "public, max-age=300")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
.authenticator(accessTokenAuthenticator)
|
.authenticator(accessTokenAuthenticator)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.build()
|
.build()
|
||||||
@@ -54,4 +94,11 @@ object NetworkModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
||||||
|
|
||||||
|
private fun hasNetwork(context: Context): Boolean {
|
||||||
|
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||||
|
val network = manager.activeNetwork ?: return false
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package de.harheimertc.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.Types
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SecureOfflineCache @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val moshi: Moshi,
|
||||||
|
) {
|
||||||
|
private val preferences by lazy {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"harheimertc_offline_cache",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putBirthdays(response: BirthdaysResponse) = put("birthdays", response, BirthdaysResponse::class.java)
|
||||||
|
fun getBirthdays(): BirthdaysResponse? = get("birthdays", BirthdaysResponse::class.java)
|
||||||
|
|
||||||
|
fun putMembers(response: MembersResponse) = put("members", response, MembersResponse::class.java)
|
||||||
|
fun getMembers(): MembersResponse? = get("members", MembersResponse::class.java)
|
||||||
|
|
||||||
|
fun putNews(response: NewsResponse) = put("member_news", response, NewsResponse::class.java)
|
||||||
|
fun getNews(): NewsResponse? = get("member_news", NewsResponse::class.java)
|
||||||
|
|
||||||
|
fun putConfig(response: ConfigResponse) = put("cms_config", response, ConfigResponse::class.java)
|
||||||
|
fun getConfig(): ConfigResponse? = get("cms_config", ConfigResponse::class.java)
|
||||||
|
|
||||||
|
fun putCmsUsers(response: CmsUsersResponse) = put("cms_users", response, CmsUsersResponse::class.java)
|
||||||
|
fun getCmsUsers(): CmsUsersResponse? = get("cms_users", CmsUsersResponse::class.java)
|
||||||
|
|
||||||
|
fun putContactRequests(response: List<ContactRequestDto>) {
|
||||||
|
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||||
|
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
|
||||||
|
preferences.edit().putString("contact_requests", json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContactRequests(): List<ContactRequestDto>? {
|
||||||
|
val json = preferences.getString("contact_requests", null) ?: return null
|
||||||
|
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||||
|
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putNewsletters(response: NewsletterListResponse) = put("newsletters", response, NewsletterListResponse::class.java)
|
||||||
|
fun getNewsletters(): NewsletterListResponse? = get("newsletters", NewsletterListResponse::class.java)
|
||||||
|
|
||||||
|
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put("newsletter_groups", response, NewsletterGroupsResponse::class.java)
|
||||||
|
fun getNewsletterGroups(): NewsletterGroupsResponse? = get("newsletter_groups", NewsletterGroupsResponse::class.java)
|
||||||
|
|
||||||
|
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
|
||||||
|
put("password_reset_diagnostics", response, PasswordResetDiagnosticsResponse::class.java)
|
||||||
|
fun getPasswordResetDiagnostics(): PasswordResetDiagnosticsResponse? =
|
||||||
|
get("password_reset_diagnostics", PasswordResetDiagnosticsResponse::class.java)
|
||||||
|
|
||||||
|
private fun <T> put(key: String, value: T, type: Class<T>) {
|
||||||
|
val json = moshi.adapter(type).toJson(value)
|
||||||
|
preferences.edit().putString(key, json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> get(key: String, type: Class<T>): T? {
|
||||||
|
val json = preferences.getString(key, null) ?: return null
|
||||||
|
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,8 @@ interface AuthRepository {
|
|||||||
fun getSessionId(): String?
|
fun getSessionId(): String?
|
||||||
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
|
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
|
||||||
fun clearSession()
|
fun clearSession()
|
||||||
|
// Device binding via Android Keystore (optional enhancement)
|
||||||
|
fun ensureDeviceKey(): String?
|
||||||
|
fun getDevicePublicKey(): String?
|
||||||
|
fun signWithDeviceKey(data: ByteArray): ByteArray?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import android.content.Context
|
|||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.harheimertc.security.DeviceKeyManager
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val deviceKeyManager: DeviceKeyManager,
|
||||||
|
) : AuthRepository {
|
||||||
private val tokenKey = "auth_token"
|
private val tokenKey = "auth_token"
|
||||||
private val refreshTokenKey = "auth_refresh_token"
|
private val refreshTokenKey = "auth_refresh_token"
|
||||||
private val sessionIdKey = "auth_session_id"
|
private val sessionIdKey = "auth_session_id"
|
||||||
@@ -46,4 +50,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
|
|||||||
.remove(sessionIdKey)
|
.remove(sessionIdKey)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keystore / device binding helpers
|
||||||
|
override fun ensureDeviceKey(): String? = try {
|
||||||
|
deviceKeyManager.ensureKeyPair()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDevicePublicKey(): String? = try {
|
||||||
|
deviceKeyManager.getPublicKeyBase64()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun signWithDeviceKey(data: ByteArray): ByteArray? = try {
|
||||||
|
deviceKeyManager.sign(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,42 +7,101 @@ import de.harheimertc.data.ContactRequestDto
|
|||||||
import de.harheimertc.data.NewsletterGroupsResponse
|
import de.harheimertc.data.NewsletterGroupsResponse
|
||||||
import de.harheimertc.data.NewsletterListResponse
|
import de.harheimertc.data.NewsletterListResponse
|
||||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||||
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CmsRepository @Inject constructor(private val api: ApiService) {
|
class CmsRepository @Inject constructor(
|
||||||
suspend fun config(): Result<ConfigResponse> = runCatching {
|
private val api: ApiService,
|
||||||
val response = api.config()
|
private val cache: SecureOfflineCache,
|
||||||
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
|
) {
|
||||||
|
suspend fun config(): Result<ConfigResponse> =
|
||||||
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.config()
|
||||||
|
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putConfig,
|
||||||
|
cached = cache::getConfig,
|
||||||
|
fallbackMessage = "Konfiguration konnte nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun saveConfig(config: ConfigResponse): Result<ConfigResponse> = runCatching {
|
||||||
|
val response = api.updateConfig(config)
|
||||||
|
if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.")
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun users(): Result<CmsUsersResponse> = runCatching {
|
suspend fun users(): Result<CmsUsersResponse> =
|
||||||
val response = api.cmsUsers()
|
fetchEncryptedFallback(
|
||||||
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
|
load = {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
val response = api.cmsUsers()
|
||||||
}
|
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putCmsUsers,
|
||||||
|
cached = cache::getCmsUsers,
|
||||||
|
fallbackMessage = "Benutzer konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun contactRequests(): Result<List<ContactRequestDto>> = runCatching {
|
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
|
||||||
val response = api.contactRequests()
|
fetchEncryptedFallback(
|
||||||
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
load = {
|
||||||
response.body() ?: emptyList()
|
val response = api.contactRequests()
|
||||||
}
|
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: emptyList()
|
||||||
|
},
|
||||||
|
save = cache::putContactRequests,
|
||||||
|
cached = cache::getContactRequests,
|
||||||
|
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun newsletters(): Result<NewsletterListResponse> = runCatching {
|
suspend fun newsletters(): Result<NewsletterListResponse> =
|
||||||
val response = api.newsletters()
|
fetchEncryptedFallback(
|
||||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
load = {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
val response = api.newsletters()
|
||||||
}
|
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putNewsletters,
|
||||||
|
cached = cache::getNewsletters,
|
||||||
|
fallbackMessage = "Newsletter konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> = runCatching {
|
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> =
|
||||||
val response = api.newsletterGroups()
|
fetchEncryptedFallback(
|
||||||
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
|
load = {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
val response = api.newsletterGroups()
|
||||||
}
|
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putNewsletterGroups,
|
||||||
|
cached = cache::getNewsletterGroups,
|
||||||
|
fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun passwordResetDiagnostics(): Result<PasswordResetDiagnosticsResponse> = runCatching {
|
suspend fun passwordResetDiagnostics(): Result<PasswordResetDiagnosticsResponse> =
|
||||||
val response = api.passwordResetDiagnostics()
|
fetchEncryptedFallback(
|
||||||
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
load = {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
val response = api.passwordResetDiagnostics()
|
||||||
|
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putPasswordResetDiagnostics,
|
||||||
|
cached = cache::getPasswordResetDiagnostics,
|
||||||
|
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun <T> fetchEncryptedFallback(
|
||||||
|
load: suspend () -> T,
|
||||||
|
save: (T) -> Unit,
|
||||||
|
cached: () -> T?,
|
||||||
|
fallbackMessage: String,
|
||||||
|
): Result<T> = runCatching {
|
||||||
|
runCatching { load() }
|
||||||
|
.onSuccess(save)
|
||||||
|
.getOrElse { original ->
|
||||||
|
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,45 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
|
import de.harheimertc.data.GalleryImageDto
|
||||||
|
import de.harheimertc.data.GalleryPaginationDto
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class GalleryRepository @Inject constructor(private val api: ApiService) {
|
class GalleryRepository @Inject constructor(
|
||||||
|
private val api: ApiService,
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||||
val response = api.publicGalleryImages()
|
val response = api.galerieList(page = 1, perPage = 1)
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
response.body().orEmpty().isNotEmpty()
|
response.body()?.images.orEmpty().isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchImages(): Result<List<String>> {
|
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||||
return try {
|
return try {
|
||||||
val resp = api.galerieList()
|
val resp = api.galerieList(page = page, perPage = perPage)
|
||||||
if (resp.isSuccessful) {
|
if (resp.isSuccessful) {
|
||||||
Result.success(resp.body() ?: emptyList())
|
val body = resp.body()
|
||||||
|
Result.success(
|
||||||
|
GalleryPage(
|
||||||
|
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||||
|
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
Result.failure(Exception("HTTP ${resp.code()}"))
|
||||||
}
|
}
|
||||||
@@ -24,4 +47,78 @@ class GalleryRepository @Inject constructor(private val api: ApiService) {
|
|||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun uploadImage(uri: Uri, title: String, description: String, isPublic: Boolean): Result<Unit> = runCatching {
|
||||||
|
val titleValue = title.trim()
|
||||||
|
require(titleValue.isNotBlank()) { "Bitte einen Titel eintragen." }
|
||||||
|
|
||||||
|
val uploadFile = prepareCompressedUploadFile(uri)
|
||||||
|
val mediaType = "image/jpeg".toMediaType()
|
||||||
|
val imageBody = uploadFile.asRequestBody(mediaType)
|
||||||
|
val imagePart = MultipartBody.Part.createFormData("image", uploadFile.name, imageBody)
|
||||||
|
val textType = "text/plain".toMediaType()
|
||||||
|
|
||||||
|
val response = api.uploadGalleryImage(
|
||||||
|
image = imagePart,
|
||||||
|
title = titleValue.toRequestBody(textType),
|
||||||
|
description = description.trim().toRequestBody(textType),
|
||||||
|
isPublic = isPublic.toString().toRequestBody(textType),
|
||||||
|
)
|
||||||
|
uploadFile.delete()
|
||||||
|
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||||
|
val body = response.body()
|
||||||
|
if (body?.success == false) error(body.message ?: "Fehler beim Hochladen des Bildes")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GalleryImageDto.toGalleryImage(): GalleryImage {
|
||||||
|
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||||
|
return GalleryImage(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
isPublic = isPublic,
|
||||||
|
uploadedAt = uploadedAt,
|
||||||
|
previewUrl = "$base/api/media/galerie/$id?preview=true",
|
||||||
|
imageUrl = "$base/api/media/galerie/$id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareCompressedUploadFile(uri: Uri): File {
|
||||||
|
val inputBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
?: error("Bilddatei konnte nicht gelesen werden.")
|
||||||
|
val original = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)
|
||||||
|
?: error("Bilddatei konnte nicht verarbeitet werden.")
|
||||||
|
val scaled = original.scaleInside(maxSize = 2000)
|
||||||
|
val file = File(context.cacheDir, "gallery_upload_${System.currentTimeMillis()}.jpg")
|
||||||
|
FileOutputStream(file).use { out ->
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||||
|
}
|
||||||
|
if (scaled !== original) scaled.recycle()
|
||||||
|
original.recycle()
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bitmap.scaleInside(maxSize: Int): Bitmap {
|
||||||
|
val largestSide = maxOf(width, height)
|
||||||
|
if (largestSide <= maxSize) return this
|
||||||
|
val scale = maxSize.toFloat() / largestSide.toFloat()
|
||||||
|
val nextWidth = (width * scale).toInt().coerceAtLeast(1)
|
||||||
|
val nextHeight = (height * scale).toInt().coerceAtLeast(1)
|
||||||
|
return Bitmap.createScaledBitmap(this, nextWidth, nextHeight, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class GalleryImage(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val isPublic: Boolean,
|
||||||
|
val uploadedAt: String?,
|
||||||
|
val previewUrl: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GalleryPage(
|
||||||
|
val images: List<GalleryImage>,
|
||||||
|
val pagination: GalleryPaginationDto,
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,26 +5,48 @@ import de.harheimertc.data.BirthdaysResponse
|
|||||||
import de.harheimertc.data.MembersResponse
|
import de.harheimertc.data.MembersResponse
|
||||||
import de.harheimertc.data.NewsResponse
|
import de.harheimertc.data.NewsResponse
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MemberAreaRepository @Inject constructor(private val api: ApiService) {
|
class MemberAreaRepository @Inject constructor(
|
||||||
suspend fun birthdays(): Result<BirthdaysResponse> = runCatching {
|
private val api: ApiService,
|
||||||
val response = api.birthdays()
|
private val cache: SecureOfflineCache,
|
||||||
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
|
) {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
suspend fun birthdays(): Result<BirthdaysResponse> =
|
||||||
}
|
fetchEncryptedFallback(
|
||||||
|
load = {
|
||||||
|
val response = api.birthdays()
|
||||||
|
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putBirthdays,
|
||||||
|
cached = cache::getBirthdays,
|
||||||
|
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun members(): Result<MembersResponse> = runCatching {
|
suspend fun members(): Result<MembersResponse> =
|
||||||
val response = api.members()
|
fetchEncryptedFallback(
|
||||||
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
|
load = {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
val response = api.members()
|
||||||
}
|
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putMembers,
|
||||||
|
cached = cache::getMembers,
|
||||||
|
fallbackMessage = "Mitglieder konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun news(): Result<NewsResponse> = runCatching {
|
suspend fun news(): Result<NewsResponse> =
|
||||||
val response = api.memberNews()
|
fetchEncryptedFallback(
|
||||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
load = {
|
||||||
response.body() ?: error("Leere Antwort vom Server.")
|
val response = api.memberNews()
|
||||||
}
|
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||||
|
response.body() ?: error("Leere Antwort vom Server.")
|
||||||
|
},
|
||||||
|
save = cache::putNews,
|
||||||
|
cached = cache::getNews,
|
||||||
|
fallbackMessage = "News konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
|
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
|
||||||
val response = api.saveNews(request)
|
val response = api.saveNews(request)
|
||||||
@@ -35,4 +57,17 @@ class MemberAreaRepository @Inject constructor(private val api: ApiService) {
|
|||||||
val response = api.deleteNews(id)
|
val response = api.deleteNews(id)
|
||||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> fetchEncryptedFallback(
|
||||||
|
load: suspend () -> T,
|
||||||
|
save: (T) -> Unit,
|
||||||
|
cached: () -> T?,
|
||||||
|
fallbackMessage: String,
|
||||||
|
): Result<T> = runCatching {
|
||||||
|
runCatching { load() }
|
||||||
|
.onSuccess(save)
|
||||||
|
.getOrElse { original ->
|
||||||
|
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.harheimertc.security
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DeviceKeyManager @Inject constructor(@param:ApplicationContext private val context: Context) {
|
||||||
|
private val alias = "harheimertc_device_key"
|
||||||
|
private val keyStore: KeyStore by lazy {
|
||||||
|
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureKeyPair(): String? {
|
||||||
|
try {
|
||||||
|
if (!keyStore.containsAlias(alias)) {
|
||||||
|
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
|
||||||
|
val specBuilder = KeyGenParameterSpec.Builder(
|
||||||
|
alias,
|
||||||
|
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||||
|
)
|
||||||
|
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||||
|
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
|
||||||
|
// For older APIs, KeyGenParameterSpec.Builder methods exist from API 23+
|
||||||
|
kpg.initialize(specBuilder.build())
|
||||||
|
kpg.generateKeyPair()
|
||||||
|
}
|
||||||
|
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||||
|
return Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPublicKeyBase64(): String? {
|
||||||
|
return try {
|
||||||
|
if (!keyStore.containsAlias(alias)) return null
|
||||||
|
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||||
|
Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sign(data: ByteArray): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val privateKey = keyStore.getKey(alias, null) as? java.security.PrivateKey ?: return null
|
||||||
|
val sig = Signature.getInstance("SHA256withECDSA")
|
||||||
|
sig.initSign(privateKey)
|
||||||
|
sig.update(data)
|
||||||
|
sig.sign()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteKey() {
|
||||||
|
try {
|
||||||
|
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -25,21 +24,35 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import de.harheimertc.R
|
||||||
|
import de.harheimertc.repositories.GalleryImage
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
|
fun ImageGrid(images: List<GalleryImage>, modifier: Modifier = Modifier) {
|
||||||
val selected = remember { mutableStateOf<String?>(null) }
|
val selected = remember { mutableStateOf<GalleryImage?>(null) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
|
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
|
||||||
items(images) { img ->
|
items(images) { img ->
|
||||||
|
val description = stringResource(R.string.gallery_image_description, img.title.ifBlank { img.id })
|
||||||
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = img,
|
model = ImageRequest.Builder(context)
|
||||||
contentDescription = "Gallery image",
|
.data(img.previewUrl)
|
||||||
|
.size(300, 300)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = description,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
|
.semantics { contentDescription = description }
|
||||||
.clickable { selected.value = img },
|
.clickable { selected.value = img },
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
@@ -51,9 +64,20 @@ fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
|
|||||||
Dialog(onDismissRequest = { selected.value = null }) {
|
Dialog(onDismissRequest = { selected.value = null }) {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(model = selected.value, contentDescription = "Full image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
|
AsyncImage(
|
||||||
Button(onClick = { selected.value = null }, modifier = Modifier.align(Alignment.TopEnd), colors = ButtonDefaults.buttonColors()) {
|
model = selected.value?.imageUrl,
|
||||||
Text("Schließen")
|
contentDescription = selected.value?.title ?: stringResource(R.string.gallery_title),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { selected.value = null },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.semantics { contentDescription = context.getString(R.string.gallery_close_image) },
|
||||||
|
colors = ButtonDefaults.buttonColors(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.gallery_upload_hide))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NativeRichTextEditor(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var fieldValue by remember(value) { mutableStateOf(TextFieldValue(value, TextRange(value.length))) }
|
||||||
|
var linkDialog by remember { mutableStateOf(false) }
|
||||||
|
var imageDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun commit(next: TextFieldValue) {
|
||||||
|
fieldValue = next
|
||||||
|
onValueChange(normalizeEmptyHtml(next.text))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleLarge)
|
||||||
|
ToolbarRow(
|
||||||
|
onAction = { action ->
|
||||||
|
when (action) {
|
||||||
|
RichTextAction.Link -> linkDialog = true
|
||||||
|
RichTextAction.Image -> imageDialog = true
|
||||||
|
RichTextAction.Clean -> commit(fieldValue.copy(text = stripHtml(fieldValue.text), selection = TextRange(stripHtml(fieldValue.text).length)))
|
||||||
|
else -> commit(applyAction(fieldValue, action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fieldValue,
|
||||||
|
onValueChange = { commit(it) },
|
||||||
|
label = { Text("HTML-Inhalt") },
|
||||||
|
minLines = 12,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Surface(color = Color(0xFFF4F4F5), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Vorschau", style = MaterialTheme.typography.labelLarge)
|
||||||
|
if (fieldValue.text.isBlank()) Text("Noch kein Inhalt.", color = Color(0xFF71717A)) else RichText(fieldValue.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkDialog) {
|
||||||
|
UrlDialog(
|
||||||
|
title = "Link einfügen",
|
||||||
|
placeholder = "https://...",
|
||||||
|
onDismiss = { linkDialog = false },
|
||||||
|
onConfirm = { url ->
|
||||||
|
commit(applyLink(fieldValue, url))
|
||||||
|
linkDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (imageDialog) {
|
||||||
|
UrlDialog(
|
||||||
|
title = "Bild einfügen",
|
||||||
|
placeholder = "https://.../bild.jpg",
|
||||||
|
onDismiss = { imageDialog = false },
|
||||||
|
onConfirm = { url ->
|
||||||
|
commit(insertHtml(fieldValue, """<p><img src="${escapeHtml(url)}"></p>"""))
|
||||||
|
imageDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolbarRow(onAction: (RichTextAction) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
RichTextAction.entries.forEach { action ->
|
||||||
|
AssistChip(onClick = { onAction(action) }, label = { Text(action.label) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UrlDialog(title: String, placeholder: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||||
|
var value by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(placeholder) }, singleLine = true)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text("Einfügen") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class RichTextAction(val label: String) {
|
||||||
|
H1("H1"),
|
||||||
|
H2("H2"),
|
||||||
|
H3("H3"),
|
||||||
|
Bold("B"),
|
||||||
|
Italic("I"),
|
||||||
|
Underline("U"),
|
||||||
|
Strike("S"),
|
||||||
|
Color("Farbe"),
|
||||||
|
Background("Marker"),
|
||||||
|
OrderedList("1."),
|
||||||
|
BulletList("•"),
|
||||||
|
AlignCenter("Zentriert"),
|
||||||
|
Link("Link"),
|
||||||
|
Image("Bild"),
|
||||||
|
Blockquote("Zitat"),
|
||||||
|
CodeBlock("Code"),
|
||||||
|
Clean("Clean"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAction(value: TextFieldValue, action: RichTextAction): TextFieldValue = when (action) {
|
||||||
|
RichTextAction.H1 -> wrapBlock(value, "h1")
|
||||||
|
RichTextAction.H2 -> wrapBlock(value, "h2")
|
||||||
|
RichTextAction.H3 -> wrapBlock(value, "h3")
|
||||||
|
RichTextAction.Bold -> wrapInline(value, "strong")
|
||||||
|
RichTextAction.Italic -> wrapInline(value, "em")
|
||||||
|
RichTextAction.Underline -> wrapInline(value, "u")
|
||||||
|
RichTextAction.Strike -> wrapInline(value, "s")
|
||||||
|
RichTextAction.Color -> wrapInline(value, "span", " style=\"color: #dc2626;\"")
|
||||||
|
RichTextAction.Background -> wrapInline(value, "span", " style=\"background-color: #fef3c7;\"")
|
||||||
|
RichTextAction.OrderedList -> wrapLines(value, "ol")
|
||||||
|
RichTextAction.BulletList -> wrapLines(value, "ul")
|
||||||
|
RichTextAction.AlignCenter -> wrapSelection(value, """<p class="ql-align-center">""", "</p>")
|
||||||
|
RichTextAction.Blockquote -> wrapBlock(value, "blockquote")
|
||||||
|
RichTextAction.CodeBlock -> wrapSelection(value, """<pre class="ql-syntax" spellcheck="false">""", "</pre>")
|
||||||
|
RichTextAction.Link,
|
||||||
|
RichTextAction.Image,
|
||||||
|
RichTextAction.Clean -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyLink(value: TextFieldValue, url: String): TextFieldValue {
|
||||||
|
val safeUrl = escapeHtml(url)
|
||||||
|
val label = selectedText(value).ifBlank { safeUrl }
|
||||||
|
return replaceSelection(value, """<a href="$safeUrl">$label</a>""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrapInline(value: TextFieldValue, tag: String, attrs: String = ""): TextFieldValue =
|
||||||
|
wrapSelection(value, "<$tag$attrs>", "</$tag>")
|
||||||
|
|
||||||
|
private fun wrapBlock(value: TextFieldValue, tag: String): TextFieldValue =
|
||||||
|
wrapSelection(value, "<$tag>", "</$tag>")
|
||||||
|
|
||||||
|
private fun wrapLines(value: TextFieldValue, listTag: String): TextFieldValue {
|
||||||
|
val lines = selectedText(value).ifBlank { "Listeneintrag" }
|
||||||
|
.lines()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString("") { "<li>${escapeHtml(it)}</li>" }
|
||||||
|
return replaceSelection(value, "<$listTag>$lines</$listTag>")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrapSelection(value: TextFieldValue, prefix: String, suffix: String): TextFieldValue =
|
||||||
|
replaceSelection(value, prefix + selectedText(value).ifBlank { "Text" } + suffix)
|
||||||
|
|
||||||
|
private fun insertHtml(value: TextFieldValue, html: String): TextFieldValue = replaceSelection(value, html)
|
||||||
|
|
||||||
|
private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue {
|
||||||
|
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||||
|
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||||
|
val next = value.text.replaceRange(start, end, replacement)
|
||||||
|
val cursor = start + replacement.length
|
||||||
|
return TextFieldValue(next, TextRange(cursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectedText(value: TextFieldValue): String {
|
||||||
|
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||||
|
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||||
|
return value.text.substring(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeEmptyHtml(value: String): String =
|
||||||
|
if (stripHtml(value).isBlank() && !value.contains("<img", ignoreCase = true)) "" else value
|
||||||
|
|
||||||
|
private fun stripHtml(value: String): String = value
|
||||||
|
.replace(Regex("<[^>]+>"), "")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
private fun escapeHtml(value: String): String = value
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
@@ -12,14 +12,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -32,6 +37,8 @@ import de.harheimertc.data.ContactRequestDto
|
|||||||
import de.harheimertc.data.NewsletterDto
|
import de.harheimertc.data.NewsletterDto
|
||||||
import de.harheimertc.data.NewsletterGroupDto
|
import de.harheimertc.data.NewsletterGroupDto
|
||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
|
import de.harheimertc.ui.components.FormMessages
|
||||||
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
@@ -61,11 +68,64 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
|
|||||||
@Composable
|
@Composable
|
||||||
fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
CmsConfigPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte", state.config) { config ->
|
val config = state.config
|
||||||
InfoRow("Über uns", textState(config.seiten.ueberUns))
|
var ueberUns by remember { mutableStateOf("") }
|
||||||
InfoRow("Geschichte", textState(config.seiten.geschichte))
|
var geschichte by remember { mutableStateOf("") }
|
||||||
InfoRow("Satzung", if (config.seiten.satzung.pdfUrl.isNotBlank()) config.seiten.satzung.pdfUrl else textState(config.seiten.satzung.content))
|
var ttRegeln by remember { mutableStateOf("") }
|
||||||
InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen")
|
var satzungContent by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(config) {
|
||||||
|
config?.let {
|
||||||
|
ueberUns = it.seiten.ueberUns
|
||||||
|
geschichte = it.seiten.geschichte
|
||||||
|
ttRegeln = it.seiten.ttRegeln
|
||||||
|
satzungContent = it.seiten.satzung.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
||||||
|
when {
|
||||||
|
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||||
|
else -> {
|
||||||
|
item {
|
||||||
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
|
Text("Native Rich-Text-Bearbeitung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
|
Text("Gespeichert wird derselbe HTML-String, den auch der Web-Editor verwendet.", color = Accent500)
|
||||||
|
NativeRichTextEditor(ueberUns, { ueberUns = it }, "Über uns")
|
||||||
|
NativeRichTextEditor(geschichte, { geschichte = it }, "Geschichte")
|
||||||
|
NativeRichTextEditor(ttRegeln, { ttRegeln = it }, "TT-Regeln")
|
||||||
|
NativeRichTextEditor(satzungContent, { satzungContent = it }, "Satzung")
|
||||||
|
FormMessages(state.error, state.message)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.saveConfig(
|
||||||
|
config.copy(
|
||||||
|
seiten = config.seiten.copy(
|
||||||
|
ueberUns = ueberUns,
|
||||||
|
geschichte = geschichte,
|
||||||
|
ttRegeln = ttRegeln,
|
||||||
|
satzung = config.seiten.satzung.copy(content = satzungContent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = !state.saving,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (state.saving) "Speichert..." else "Inhalte speichern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Strukturierte Inhalte") {
|
||||||
|
InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen")
|
||||||
|
InfoRow("Satzung-PDF", config.seiten.satzung.pdfUrl.ifBlank { "Nicht gesetzt" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
data class CmsUiState(
|
data class CmsUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
|
val saving: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
val config: ConfigResponse? = null,
|
val config: ConfigResponse? = null,
|
||||||
val users: List<CmsUserDto> = emptyList(),
|
val users: List<CmsUserDto> = emptyList(),
|
||||||
val contactRequests: List<ContactRequestDto> = emptyList(),
|
val contactRequests: List<ContactRequestDto> = emptyList(),
|
||||||
@@ -59,4 +61,24 @@ class CmsViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveConfig(config: ConfigResponse) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
|
repository.saveConfig(config)
|
||||||
|
.onSuccess { saved ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
config = saved,
|
||||||
|
message = "Inhalt gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
saving = false,
|
||||||
|
error = it.message ?: "Inhalt konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,41 @@
|
|||||||
package de.harheimertc.ui.screens.gallery
|
package de.harheimertc.ui.screens.gallery
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import de.harheimertc.R
|
||||||
|
import de.harheimertc.ui.components.FormMessages
|
||||||
import de.harheimertc.ui.components.ImageGrid
|
import de.harheimertc.ui.components.ImageGrid
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -17,17 +43,104 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
|||||||
val images by viewModel.images.collectAsState()
|
val images by viewModel.images.collectAsState()
|
||||||
val loading by viewModel.loading.collectAsState()
|
val loading by viewModel.loading.collectAsState()
|
||||||
val error by viewModel.error.collectAsState()
|
val error by viewModel.error.collectAsState()
|
||||||
|
val uploading by viewModel.uploading.collectAsState()
|
||||||
|
val message by viewModel.message.collectAsState()
|
||||||
|
val canUpload by viewModel.canUpload.collectAsState()
|
||||||
|
var selectedUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
|
var title by remember { mutableStateOf("") }
|
||||||
|
var description by remember { mutableStateOf("") }
|
||||||
|
var isPublic by remember { mutableStateOf(false) }
|
||||||
|
var showUpload by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
selectedUri = uri
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Column(
|
||||||
if (loading) {
|
modifier = Modifier
|
||||||
CircularProgressIndicator()
|
.fillMaxSize()
|
||||||
} else if (error != null) {
|
.verticalScroll(rememberScrollState())
|
||||||
Text(text = "Fehler: $error")
|
.padding(16.dp),
|
||||||
} else {
|
) {
|
||||||
ImageGrid(images = images)
|
Text(
|
||||||
|
text = stringResource(R.string.gallery_title),
|
||||||
|
modifier = Modifier.semantics { heading() },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (canUpload) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.gallery_upload_title), modifier = Modifier.weight(1f))
|
||||||
|
OutlinedButton(onClick = { showUpload = !showUpload }) {
|
||||||
|
Text(if (showUpload) stringResource(R.string.gallery_upload_hide) else stringResource(R.string.gallery_upload_show))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showUpload) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { picker.launch("image/*") },
|
||||||
|
enabled = !uploading,
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
contentDescription = context.getString(R.string.gallery_upload_choose_file)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(selectedUri?.lastPathSegment ?: stringResource(R.string.gallery_upload_choose_file))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
label = { Text(stringResource(R.string.gallery_upload_image_title)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uploading,
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
label = { Text(stringResource(R.string.gallery_upload_description)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uploading,
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it }, enabled = !uploading)
|
||||||
|
Text(stringResource(R.string.gallery_upload_public))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
selectedUri?.let { uri ->
|
||||||
|
viewModel.upload(uri, title, description, isPublic)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = selectedUri != null && title.isNotBlank() && !uploading,
|
||||||
|
) {
|
||||||
|
Text(if (uploading) stringResource(R.string.gallery_uploading) else stringResource(R.string.gallery_upload_submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
FormMessages(error = error, message = message)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else if (images.isEmpty()) {
|
||||||
|
Text(text = stringResource(R.string.gallery_empty))
|
||||||
|
} else {
|
||||||
|
ImageGrid(images = images, modifier = Modifier.height(520.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// load on first composition
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
|
viewModel.load()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,22 @@ package de.harheimertc.ui.screens.gallery
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import android.net.Uri
|
||||||
|
import de.harheimertc.repositories.GalleryImage
|
||||||
import de.harheimertc.repositories.GalleryRepository
|
import de.harheimertc.repositories.GalleryRepository
|
||||||
|
import de.harheimertc.repositories.LoginRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) : ViewModel() {
|
class GalleryViewModel @Inject constructor(
|
||||||
private val _images = MutableStateFlow<List<String>>(emptyList())
|
private val repo: GalleryRepository,
|
||||||
val images: StateFlow<List<String>> = _images
|
private val loginRepository: LoginRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _images = MutableStateFlow<List<GalleryImage>>(emptyList())
|
||||||
|
val images: StateFlow<List<GalleryImage>> = _images
|
||||||
|
|
||||||
private val _loading = MutableStateFlow(false)
|
private val _loading = MutableStateFlow(false)
|
||||||
val loading: StateFlow<Boolean> = _loading
|
val loading: StateFlow<Boolean> = _loading
|
||||||
@@ -20,14 +26,43 @@ class GalleryViewModel @Inject constructor(private val repo: GalleryRepository)
|
|||||||
private val _error = MutableStateFlow<String?>(null)
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
val error: StateFlow<String?> = _error
|
val error: StateFlow<String?> = _error
|
||||||
|
|
||||||
|
private val _uploading = MutableStateFlow(false)
|
||||||
|
val uploading: StateFlow<Boolean> = _uploading
|
||||||
|
|
||||||
|
private val _message = MutableStateFlow<String?>(null)
|
||||||
|
val message: StateFlow<String?> = _message
|
||||||
|
|
||||||
|
private val _canUpload = MutableStateFlow(false)
|
||||||
|
val canUpload: StateFlow<Boolean> = _canUpload
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_loading.value = true
|
_loading.value = true
|
||||||
_error.value = null
|
_error.value = null
|
||||||
repo.fetchImages()
|
repo.fetchImages()
|
||||||
.onSuccess { _images.value = it }
|
.onSuccess { _images.value = it.images }
|
||||||
.onFailure { _error.value = it.message ?: "Fehler" }
|
.onFailure { _error.value = it.message ?: "Fehler" }
|
||||||
|
loginRepository.status()
|
||||||
|
.onSuccess { status ->
|
||||||
|
val roles = (status.roles + status.user?.roles.orEmpty() + listOfNotNull(status.role)).toSet()
|
||||||
|
_canUpload.value = roles.any { it in setOf("admin", "vorstand") }
|
||||||
|
}
|
||||||
_loading.value = false
|
_loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun upload(uri: Uri, title: String, description: String, isPublic: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uploading.value = true
|
||||||
|
_error.value = null
|
||||||
|
_message.value = null
|
||||||
|
repo.uploadImage(uri, title, description, isPublic)
|
||||||
|
.onSuccess {
|
||||||
|
_message.value = "Bild erfolgreich hochgeladen."
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
.onFailure { _error.value = it.message ?: "Fehler beim Hochladen des Bildes" }
|
||||||
|
_uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
android-app/app/src/main/res/values-en/strings.xml
Normal file
16
android-app/app/src/main/res/values-en/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Harheimer TC</string>
|
||||||
|
<string name="gallery_title">Photo gallery</string>
|
||||||
|
<string name="gallery_empty">There are no images in the gallery yet.</string>
|
||||||
|
<string name="gallery_upload_title">Upload image</string>
|
||||||
|
<string name="gallery_upload_show">Open</string>
|
||||||
|
<string name="gallery_upload_hide">Close</string>
|
||||||
|
<string name="gallery_upload_choose_file">Select image file</string>
|
||||||
|
<string name="gallery_upload_image_title">Title</string>
|
||||||
|
<string name="gallery_upload_description">Description (optional)</string>
|
||||||
|
<string name="gallery_upload_public">Publicly visible</string>
|
||||||
|
<string name="gallery_upload_submit">Upload image</string>
|
||||||
|
<string name="gallery_uploading">Uploading...</string>
|
||||||
|
<string name="gallery_close_image">Close image</string>
|
||||||
|
<string name="gallery_image_description">Gallery image: %1$s</string>
|
||||||
|
</resources>
|
||||||
16
android-app/app/src/main/res/values/strings.xml
Normal file
16
android-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Harheimer TC</string>
|
||||||
|
<string name="gallery_title">Bildergalerie</string>
|
||||||
|
<string name="gallery_empty">Noch keine Bilder in der Galerie.</string>
|
||||||
|
<string name="gallery_upload_title">Bild hochladen</string>
|
||||||
|
<string name="gallery_upload_show">Öffnen</string>
|
||||||
|
<string name="gallery_upload_hide">Schließen</string>
|
||||||
|
<string name="gallery_upload_choose_file">Bilddatei auswählen</string>
|
||||||
|
<string name="gallery_upload_image_title">Titel</string>
|
||||||
|
<string name="gallery_upload_description">Beschreibung (optional)</string>
|
||||||
|
<string name="gallery_upload_public">Öffentlich sichtbar</string>
|
||||||
|
<string name="gallery_upload_submit">Bild hochladen</string>
|
||||||
|
<string name="gallery_uploading">Wird hochgeladen...</string>
|
||||||
|
<string name="gallery_close_image">Bild schließen</string>
|
||||||
|
<string name="gallery_image_description">Galeriebild: %1$s</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FormComponentsTest {
|
||||||
|
@Test
|
||||||
|
fun isValidEmailAcceptsCommonAddresses() {
|
||||||
|
assertTrue(isValidEmail("mitglied@example.de"))
|
||||||
|
assertTrue(isValidEmail(" vorstand.name+test@harheimertc.de "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isValidEmailRejectsInvalidAddresses() {
|
||||||
|
assertFalse(isValidEmail(""))
|
||||||
|
assertFalse(isValidEmail("mitglied"))
|
||||||
|
assertFalse(isValidEmail("mitglied@example"))
|
||||||
|
assertFalse(isValidEmail("mitglied @example.de"))
|
||||||
|
assertFalse(isValidEmail("mitglied@@example.de"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isValidIsoDateRequiresIsoShape() {
|
||||||
|
assertTrue(isValidIsoDate("2026-05-28"))
|
||||||
|
assertFalse(isValidIsoDate("28.05.2026"))
|
||||||
|
assertFalse(isValidIsoDate("2026-5-28"))
|
||||||
|
assertFalse(isValidIsoDate(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class CmsViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun load_populatesState() = runTest {
|
||||||
|
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||||
|
val config = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "TestFirst", nachname = "TestLast", email = "a@b")))
|
||||||
|
val users = de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U")))
|
||||||
|
coEvery { repo.config() } returns Result.success(config)
|
||||||
|
coEvery { repo.users() } returns Result.success(users)
|
||||||
|
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.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||||
|
|
||||||
|
val vm = CmsViewModel(repo)
|
||||||
|
// advance init launched coroutine
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertEquals(false, state.loading)
|
||||||
|
assertEquals("TestFirst", state.config?.website?.verantwortlicher?.vorname)
|
||||||
|
assertEquals(1, state.users.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun saveConfig_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")))
|
||||||
|
// stub load() calls during ViewModel init
|
||||||
|
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.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||||
|
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
||||||
|
val vm = CmsViewModel(repo)
|
||||||
|
|
||||||
|
// wait for init/load to finish before saving to avoid race
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.saveConfig(cfg)
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertEquals(false, state.saving)
|
||||||
|
assertEquals("Inhalt gespeichert.", state.message)
|
||||||
|
assertEquals("X", state.config?.website?.verantwortlicher?.vorname)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.harheimertc.ui.screens.gallery
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class GalleryViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun load_setsImagesAndCanUpload() = runTest {
|
||||||
|
val galleryRepo = mockk<de.harheimertc.repositories.GalleryRepository>()
|
||||||
|
val loginRepo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||||
|
val page = de.harheimertc.repositories.GalleryPage(images = listOf(de.harheimertc.repositories.GalleryImage(id = "1", title = "T", description = "D", isPublic = true, uploadedAt = null, previewUrl = "p", imageUrl = "u")), pagination = de.harheimertc.data.GalleryPaginationDto())
|
||||||
|
coEvery { galleryRepo.fetchImages() } returns Result.success(page)
|
||||||
|
coEvery { loginRepo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse(isLoggedIn = true, roles = listOf("admin")))
|
||||||
|
|
||||||
|
val vm = GalleryViewModel(galleryRepo, loginRepo)
|
||||||
|
vm.load()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, vm.images.value.size)
|
||||||
|
assertEquals(true, vm.canUpload.value)
|
||||||
|
assertEquals(false, vm.loading.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upload_callsUploadAndReloads() = runTest {
|
||||||
|
val galleryRepo = mockk<de.harheimertc.repositories.GalleryRepository>()
|
||||||
|
val loginRepo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||||
|
coEvery { galleryRepo.uploadImage(any(), any(), any(), any()) } returns Result.success(Unit)
|
||||||
|
coEvery { galleryRepo.fetchImages() } returns Result.success(de.harheimertc.repositories.GalleryPage(images = emptyList(), pagination = de.harheimertc.data.GalleryPaginationDto()))
|
||||||
|
coEvery { loginRepo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||||
|
|
||||||
|
val vm = GalleryViewModel(galleryRepo, loginRepo)
|
||||||
|
val testUri = mockk<Uri>()
|
||||||
|
vm.upload(testUri, "t", "d", true)
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(false, vm.uploading.value)
|
||||||
|
assertEquals("Bild erfolgreich hochgeladen.", vm.message.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.harheimertc.ui.screens.login
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import retrofit2.Response
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
// Fake implementations to avoid network/Hilt in unit tests
|
||||||
|
// Simple stubs to avoid importing the real repositories and Hilt wiring in tests
|
||||||
|
// Use mockk to mock the repositories directly for unit testing
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class LoginViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun login_showsFieldErrors_whenInvalidInput() = runTest {
|
||||||
|
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||||
|
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||||
|
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||||
|
val vm = LoginViewModel(repo, passkeyRepo)
|
||||||
|
|
||||||
|
vm.setEmail("invalid-email")
|
||||||
|
vm.setPassword("")
|
||||||
|
vm.login()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertEquals(true, state.fieldErrors.containsKey("email"))
|
||||||
|
assertEquals(true, state.fieldErrors.containsKey("password"))
|
||||||
|
assertEquals("Bitte prüfen Sie die markierten Felder.", state.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun login_success_updatesState() = runTest {
|
||||||
|
val loginResp = de.harheimertc.data.LoginResponse(user = de.harheimertc.data.AuthUserDto(name = "Max Mustermann", email = "max@ex.de", roles = listOf("user")))
|
||||||
|
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||||
|
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||||
|
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
|
||||||
|
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||||
|
val vm = LoginViewModel(repo, passkeyRepo)
|
||||||
|
|
||||||
|
vm.setEmail("max@ex.de")
|
||||||
|
vm.setPassword("secret")
|
||||||
|
vm.login()
|
||||||
|
|
||||||
|
// advance until coroutines complete
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.state.value
|
||||||
|
assertEquals(true, state.loggedIn)
|
||||||
|
assertEquals("Max Mustermann", state.userName)
|
||||||
|
assertEquals(false, state.loading)
|
||||||
|
assertEquals("Anmeldung erfolgreich.", state.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user