Chastity game angefangen

This commit is contained in:
2026-03-15 22:51:10 +01:00
parent 3e23ae788b
commit 57a7c78037
359 changed files with 27638 additions and 1109 deletions

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".XxxApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.XxxGame"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.XxxGame">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,360 @@
package de.oaa.xxx.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.*
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.oaa.xxx.app.data.model.User
import de.oaa.xxx.app.ui.navigation.Routes
import de.oaa.xxx.app.ui.screens.aufgaben.AufgabenScreen
import de.oaa.xxx.app.ui.screens.auth.LoginScreen
import de.oaa.xxx.app.ui.screens.auth.RegisterScreen
import de.oaa.xxx.app.ui.screens.feed.FeedScreen
import de.oaa.xxx.app.ui.screens.gruppen.GruppenScreen
import de.oaa.xxx.app.ui.screens.home.HomeScreen
import de.oaa.xxx.app.ui.screens.profile.ProfileScreen
import de.oaa.xxx.app.ui.screens.session.SessionInGameScreen
import de.oaa.xxx.app.ui.screens.session.SessionSetupScreen
import de.oaa.xxx.app.ui.screens.social.ConversationScreen
import de.oaa.xxx.app.ui.screens.social.FriendsScreen
import de.oaa.xxx.app.ui.screens.social.MessagesScreen
import de.oaa.xxx.app.ui.theme.XxxGameTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.AufgabenGruppenRef
import de.oaa.xxx.app.ui.viewmodel.AufgabenViewModel
import de.oaa.xxx.app.ui.viewmodel.AuthState
import de.oaa.xxx.app.ui.viewmodel.AuthViewModel
import de.oaa.xxx.app.ui.viewmodel.FeedViewModel
import de.oaa.xxx.app.ui.viewmodel.SessionViewModel
import de.oaa.xxx.app.ui.viewmodel.SocialViewModel
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
XxxGameTheme {
XxxApp()
}
}
}
}
@Composable
fun XxxApp() {
val navController = rememberNavController()
val authViewModel: AuthViewModel = viewModel()
val sessionViewModel: SessionViewModel = viewModel()
val aufgabenViewModel: AufgabenViewModel = viewModel()
val socialViewModel: SocialViewModel = viewModel()
val feedViewModel: FeedViewModel = viewModel()
val authState by authViewModel.state.collectAsState()
var currentUser by remember { mutableStateOf<User?>(null) }
var conversationPartnerName by remember { mutableStateOf("") }
LaunchedEffect(authState) {
when (authState) {
is AuthState.LoggedIn -> {
currentUser = (authState as AuthState.LoggedIn).user
navController.navigate(Routes.HOME) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
}
is AuthState.LoggedOut -> {
navController.navigate(Routes.LOGIN) {
popUpTo(0) { inclusive = true }
}
}
else -> {}
}
}
NavHost(navController = navController, startDestination = Routes.LOGIN) {
composable(Routes.LOGIN) {
LoginScreen(
viewModel = authViewModel,
onLoginSuccess = {},
onNavigateToRegister = { navController.navigate(Routes.REGISTER) }
)
}
composable(Routes.REGISTER) {
RegisterScreen(
viewModel = authViewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable(Routes.HOME) {
currentUser?.let { user ->
HomeScreen(
user = user,
onNavigate = { route -> navController.navigate(route) },
onLogout = { authViewModel.logout() }
)
}
}
composable(Routes.AUFGABEN) {
AufgabenScreen(
viewModel = aufgabenViewModel,
onNavigateBack = { navController.popBackStack() },
onGruppeClick = { gruppenId ->
navController.navigate("aufgaben/$gruppenId")
}
)
}
composable(
"aufgaben/{gruppenId}",
arguments = listOf(navArgument("gruppenId") { type = NavType.StringType })
) { backStack ->
val gruppenId = backStack.arguments?.getString("gruppenId") ?: return@composable
LaunchedEffect(gruppenId) { aufgabenViewModel.loadGruppe(gruppenId) }
// Group detail - reuse AufgabenScreen for now
AufgabenScreen(
viewModel = aufgabenViewModel,
onNavigateBack = { navController.popBackStack() },
onGruppeClick = {}
)
}
composable(Routes.SESSION_SETUP) {
currentUser?.let { user ->
SessionSetupScreen(
viewModel = sessionViewModel,
currentUser = user,
onNavigateBack = { navController.popBackStack() },
onSessionCreated = { sessionId ->
navController.navigate("session/players/$sessionId")
}
)
}
}
composable(
"session/players/{sessionId}",
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) { backStack ->
val sessionId = backStack.arguments?.getString("sessionId") ?: return@composable
// Navigate directly to task selection
navController.navigate("session/tasks/$sessionId") {
popUpTo("session/players/$sessionId") { inclusive = true }
}
}
composable(
"session/tasks/{sessionId}",
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) { backStack ->
val sessionId = backStack.arguments?.getString("sessionId") ?: return@composable
// Show task group selection for session
SessionTaskSelectionScreen(
sessionId = sessionId,
aufgabenViewModel = aufgabenViewModel,
sessionViewModel = sessionViewModel,
onNavigateBack = { navController.popBackStack() },
onReady = { navController.navigate("session/ingame/$sessionId") }
)
}
composable(
Routes.SESSION_INGAME,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) { backStack ->
val sessionId = backStack.arguments?.getString("sessionId") ?: return@composable
SessionInGameScreen(
viewModel = sessionViewModel,
sessionId = sessionId,
onSessionEnd = {
navController.navigate(Routes.HOME) {
popUpTo(Routes.HOME) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() }
)
}
composable(Routes.PROFILE) {
currentUser?.let { user ->
ProfileScreen(
currentUser = user,
onNavigateBack = { navController.popBackStack() },
onUserUpdated = { updatedUser -> currentUser = updatedUser }
)
}
}
composable(Routes.FRIENDS) {
FriendsScreen(
viewModel = socialViewModel,
currentUserId = currentUser?.userId ?: "",
onNavigateBack = { navController.popBackStack() },
onOpenMessages = { partnerId ->
navController.navigate("messages/$partnerId")
}
)
}
composable(Routes.MESSAGES) {
MessagesScreen(
viewModel = socialViewModel,
onNavigateBack = { navController.popBackStack() },
onOpenConversation = { partnerId ->
navController.navigate("messages/$partnerId")
}
)
}
composable(
"messages/{partnerId}",
arguments = listOf(navArgument("partnerId") { type = NavType.StringType })
) { backStack ->
val partnerId = backStack.arguments?.getString("partnerId") ?: return@composable
ConversationScreen(
viewModel = socialViewModel,
partnerId = partnerId,
currentUserId = currentUser?.userId ?: "",
partnerName = conversationPartnerName.ifBlank { "Konversation" },
onNavigateBack = { navController.popBackStack() }
)
}
composable(Routes.GRUPPEN) {
GruppenScreen(
currentUserId = currentUser?.userId ?: "",
onNavigateBack = { navController.popBackStack() }
)
}
composable(Routes.FEED) {
FeedScreen(
viewModel = feedViewModel,
onNavigateBack = { navController.popBackStack() }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionTaskSelectionScreen(
sessionId: String,
aufgabenViewModel: AufgabenViewModel,
sessionViewModel: SessionViewModel,
onNavigateBack: () -> Unit,
onReady: () -> Unit
) {
val userGruppen by aufgabenViewModel.userGruppen.collectAsState()
val subscriptions by aufgabenViewModel.subscriptions.collectAsState()
val isLoading by sessionViewModel.isLoading.collectAsState()
val selectedGruppenIds = remember { mutableStateListOf<String>() }
LaunchedEffect(Unit) {
aufgabenViewModel.loadUserGruppen()
aufgabenViewModel.loadSubscriptions()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Aufgaben auswählen") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { padding ->
val allGruppen = (userGruppen + subscriptions).distinctBy { it.gruppenId }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp)
) {
items(allGruppen) { gruppe ->
val isSelected = gruppe.gruppenId in selectedGruppenIds
Card(
onClick = {
if (isSelected) selectedGruppenIds.remove(gruppe.gruppenId)
else gruppe.gruppenId?.let { selectedGruppenIds.add(it) }
},
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
else MaterialTheme.colorScheme.surface
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isSelected,
onCheckedChange = {
if (isSelected) selectedGruppenIds.remove(gruppe.gruppenId)
else gruppe.gruppenId?.let { id -> selectedGruppenIds.add(id) }
}
)
Text(gruppe.name ?: "(Unbekannt)", modifier = Modifier.weight(1f))
}
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (selectedGruppenIds.isNotEmpty()) {
val refs = selectedGruppenIds.map { AufgabenGruppenRef(gruppenId = it) }
sessionViewModel.setAufgaben(sessionId, refs) { onReady() }
}
},
modifier = Modifier.fillMaxWidth().height(52.dp),
enabled = selectedGruppenIds.isNotEmpty() && !isLoading
) {
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp))
else Text("Session starten", fontSize = 16.sp)
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.app
import android.app.Application
class XxxApplication : Application() {
override fun onCreate() {
super.onCreate()
}
}

View File

@@ -0,0 +1,69 @@
package de.oaa.xxx.app.data.api
import de.oaa.xxx.app.BuildConfig
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
val cookieJar = object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
cookieStore.getOrPut(url.host) { mutableListOf() }.apply {
removeAll { existing -> cookies.any { it.name == existing.name } }
addAll(cookies)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
cookieStore[url.host] ?: emptyList()
}
fun getJwtToken(): String? =
cookieStore.values.flatten().find { it.name == "jwt" }?.value
fun setJwtToken(token: String) {
val host = HttpUrl.parse(BuildConfig.BASE_URL)?.host ?: return
val cookie = Cookie.Builder()
.name("jwt")
.value(token)
.domain(host)
.path("/")
.httpOnly()
.build()
cookieStore.getOrPut(host) { mutableListOf() }.apply {
removeAll { it.name == "jwt" }
add(cookie)
}
}
fun clearCookies() = cookieStore.clear()
private val okHttpClient = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
private val retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL + "/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val authApi: AuthApi = retrofit.create(AuthApi::class.java)
val aufgabenApi: AufgabenApi = retrofit.create(AufgabenApi::class.java)
val sessionApi: SessionApi = retrofit.create(SessionApi::class.java)
val socialApi: SocialApi = retrofit.create(SocialApi::class.java)
val gruppenApi: GruppenApi = retrofit.create(GruppenApi::class.java)
val feedApi: FeedApi = retrofit.create(FeedApi::class.java)
}

View File

@@ -0,0 +1,162 @@
package de.oaa.xxx.app.data.api
import de.oaa.xxx.app.data.model.Aufgabe
import de.oaa.xxx.app.data.model.AufgabenGruppe
import de.oaa.xxx.app.data.model.AufgabenGruppeDisplay
import de.oaa.xxx.app.data.model.Favorit
import de.oaa.xxx.app.data.model.Finisher
import de.oaa.xxx.app.data.model.Sperre
import de.oaa.xxx.app.data.model.Strafe
import de.oaa.xxx.app.data.model.Toy
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface AufgabenApi {
// --- Aufgabengruppen ---
@GET("gruppe/list/user")
suspend fun getUserGruppen(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<AufgabenGruppeDisplay>>
@GET("gruppe/list/system")
suspend fun getSystemGruppen(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<AufgabenGruppeDisplay>>
@GET("gruppe/all")
suspend fun getAllGruppen(
@Query("name") name: String? = null,
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<AufgabenGruppeDisplay>>
@GET("gruppe/{gruppeId}")
suspend fun getGruppe(@Path("gruppeId") gruppeId: String): Response<AufgabenGruppe>
@POST("gruppe")
suspend fun createGruppe(@Body gruppe: AufgabenGruppe): Response<AufgabenGruppe>
@PUT("gruppe/{gruppeId}")
suspend fun updateGruppe(
@Path("gruppeId") gruppeId: String,
@Body gruppe: AufgabenGruppe
): Response<AufgabenGruppe>
@DELETE("gruppe/{gruppeId}")
suspend fun deleteGruppe(@Path("gruppeId") gruppeId: String): Response<Unit>
@POST("gruppe/copy/{gruppeId}")
suspend fun copyGruppe(@Path("gruppeId") gruppeId: String): Response<AufgabenGruppe>
// --- Aufgaben ---
@GET("aufgabe/{aufgabeId}")
suspend fun getAufgabe(@Path("aufgabeId") aufgabeId: String): Response<Aufgabe>
@POST("aufgabe")
suspend fun createAufgabe(@Body aufgabe: Aufgabe): Response<Aufgabe>
@PUT("aufgabe/{aufgabeId}")
suspend fun updateAufgabe(
@Path("aufgabeId") aufgabeId: String,
@Body aufgabe: Aufgabe
): Response<Aufgabe>
@DELETE("aufgabe")
suspend fun deleteAufgabe(@Body aufgabe: Aufgabe): Response<Unit>
// --- Toys ---
@GET("toy/list/user")
suspend fun getUserToys(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<Toy>>
@GET("toy/list/system")
suspend fun getSystemToys(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<Toy>>
@GET("toy/available")
suspend fun getAvailableToys(): Response<List<Toy>>
@POST("toy")
suspend fun createToy(@Body toy: Toy): Response<Toy>
@PUT("toy/{toyId}")
suspend fun updateToy(@Path("toyId") toyId: String, @Body toy: Toy): Response<Toy>
@DELETE("toy/{toyId}")
suspend fun deleteToy(@Path("toyId") toyId: String): Response<Unit>
@POST("toy/copy/{toyId}")
suspend fun copyToy(@Path("toyId") toyId: String): Response<Toy>
// --- Favoriten ---
@GET("favorit")
suspend fun getFavoriten(): Response<List<Favorit>>
@POST("favorit")
suspend fun addFavorit(@Body favorit: Favorit): Response<Favorit>
@DELETE("favorit")
suspend fun removeFavorit(@Body favorit: Favorit): Response<Unit>
// --- Abonnements ---
@GET("abo/list")
suspend fun getAbonnements(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<AufgabenGruppeDisplay>>
@GET("abo/discover")
suspend fun discoverGruppen(@Query("name") name: String? = null): Response<List<AufgabenGruppeDisplay>>
@POST("abo/{gruppenId}")
suspend fun subscribe(@Path("gruppenId") gruppenId: String): Response<Unit>
@DELETE("abo/{gruppenId}")
suspend fun unsubscribe(@Path("gruppenId") gruppenId: String): Response<Unit>
// --- Sperren ---
@POST("sperre")
suspend fun createSperre(@Body sperre: Sperre): Response<Sperre>
@PUT("sperre/{sperreId}")
suspend fun updateSperre(@Path("sperreId") sperreId: String, @Body sperre: Sperre): Response<Sperre>
@DELETE("sperre")
suspend fun deleteSperre(@Body sperre: Sperre): Response<Unit>
// --- Strafen ---
@POST("strafe")
suspend fun createStrafe(@Body strafe: Strafe): Response<Strafe>
@PUT("strafe/{strafeId}")
suspend fun updateStrafe(@Path("strafeId") strafeId: String, @Body strafe: Strafe): Response<Strafe>
@DELETE("strafe")
suspend fun deleteStrafe(@Body strafe: Strafe): Response<Unit>
// --- Finisher ---
@POST("finisher")
suspend fun createFinisher(@Body finisher: Finisher): Response<Finisher>
@PUT("finisher/{finisherId}")
suspend fun updateFinisher(
@Path("finisherId") finisherId: String,
@Body finisher: Finisher
): Response<Finisher>
@DELETE("finisher")
suspend fun deleteFinisher(@Body finisher: Finisher): Response<Unit>
}

View File

@@ -0,0 +1,30 @@
package de.oaa.xxx.app.data.api
import de.oaa.xxx.app.data.model.RegistrationRequest
import de.oaa.xxx.app.data.model.User
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface AuthApi {
@GET("login")
suspend fun login(
@Query("email") email: String,
@Query("hash") hash: String
): Response<User>
@GET("login/me")
suspend fun me(): Response<User>
@GET("login/logout")
suspend fun logout(): Response<Unit>
@POST("registration")
suspend fun register(@Body request: RegistrationRequest): Response<Unit>
@POST("user")
suspend fun createUser(@Body user: User): Response<User>
}

View File

@@ -0,0 +1,45 @@
package de.oaa.xxx.app.data.api
import de.oaa.xxx.app.data.model.FeedItemDto
import de.oaa.xxx.app.data.model.FeedPostRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface FeedApi {
@POST("feed/posts")
suspend fun createPost(@Body request: FeedPostRequest): Response<FeedItemDto>
@GET("feed/mine")
suspend fun getMyFeed(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<FeedItemDto>>
@GET("feed/public")
suspend fun getPublicFeed(
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<FeedItemDto>>
@GET("feed/user/{userId}")
suspend fun getUserFeed(
@Path("userId") userId: String,
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<FeedItemDto>>
@POST("feed/posts/{id}/like")
suspend fun toggleLike(@Path("id") id: String): Response<Unit>
@POST("feed/posts/{id}/vote")
suspend fun vote(@Path("id") id: String, @Body body: Map<String, String>): Response<Unit>
@DELETE("feed/posts/{id}")
suspend fun deletePost(@Path("id") id: String): Response<Unit>
}

View File

@@ -0,0 +1,95 @@
package de.oaa.xxx.app.data.api
import de.oaa.xxx.app.data.model.BeitrittsanfrageDto
import de.oaa.xxx.app.data.model.CountResponse
import de.oaa.xxx.app.data.model.GruppeDto
import de.oaa.xxx.app.data.model.GruppenbeitragDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface GruppenApi {
@GET("gruppen/search")
suspend fun search(@Query("q") query: String): Response<List<GruppeDto>>
@GET("gruppen/mine")
suspend fun getMine(): Response<List<GruppeDto>>
@GET("gruppen/{id}")
suspend fun getGruppe(@Path("id") id: String): Response<GruppeDto>
@POST("gruppen")
suspend fun createGruppe(@Body gruppe: GruppeDto): Response<GruppeDto>
@PUT("gruppen/{id}")
suspend fun updateGruppe(@Path("id") id: String, @Body gruppe: GruppeDto): Response<GruppeDto>
@DELETE("gruppen/{id}")
suspend fun deleteGruppe(@Path("id") id: String): Response<Unit>
@POST("gruppen/{id}/join")
suspend fun join(@Path("id") id: String): Response<Unit>
@DELETE("gruppen/{id}/leave")
suspend fun leave(@Path("id") id: String): Response<Unit>
@GET("gruppen/{id}/members")
suspend fun getMembers(@Path("id") id: String): Response<List<Map<String, Any>>>
@DELETE("gruppen/{id}/members/{userId}")
suspend fun removeMember(@Path("id") id: String, @Path("userId") userId: String): Response<Unit>
@POST("gruppen/{id}/members/{userId}/promote")
suspend fun promoteMember(@Path("id") id: String, @Path("userId") userId: String): Response<Unit>
@GET("gruppen/{id}/requests")
suspend fun getRequests(@Path("id") id: String): Response<List<BeitrittsanfrageDto>>
@POST("gruppen/{id}/requests/{reqId}/approve")
suspend fun approveRequest(@Path("id") id: String, @Path("reqId") reqId: String): Response<Unit>
@DELETE("gruppen/{id}/requests/{reqId}")
suspend fun rejectRequest(@Path("id") id: String, @Path("reqId") reqId: String): Response<Unit>
@GET("gruppen/requests/pending/count")
suspend fun getPendingRequestsCount(): Response<CountResponse>
@GET("gruppen/requests/mine")
suspend fun getMyRequests(): Response<List<BeitrittsanfrageDto>>
// --- Beitraege ---
@GET("gruppen/{id}/beitraege")
suspend fun getBeitraege(
@Path("id") id: String,
@Query("page") page: Int = 0,
@Query("size") size: Int = 20
): Response<List<GruppenbeitragDto>>
@POST("gruppen/{id}/beitraege")
suspend fun createBeitrag(
@Path("id") id: String,
@Body beitrag: GruppenbeitragDto
): Response<GruppenbeitragDto>
@POST("gruppen/{id}/beitraege/{beitragId}/like")
suspend fun toggleLike(@Path("id") id: String, @Path("beitragId") beitragId: String): Response<Unit>
@POST("gruppen/{id}/beitraege/{beitragId}/vote")
suspend fun vote(
@Path("id") id: String,
@Path("beitragId") beitragId: String,
@Body body: Map<String, String>
): Response<Unit>
@DELETE("gruppen/{id}/beitraege/{beitragId}")
suspend fun deleteBeitrag(
@Path("id") id: String,
@Path("beitragId") beitragId: String
): Response<Unit>
}

View File

@@ -0,0 +1,49 @@
package de.oaa.xxx.app.data.api
import de.oaa.xxx.app.data.model.AufgabeAnzeige
import de.oaa.xxx.app.data.model.AufgabenList
import de.oaa.xxx.app.data.model.Mitspieler
import de.oaa.xxx.app.data.model.Session
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface SessionApi {
@GET("session/{sessionId}")
suspend fun getBySessionId(@Path("sessionId") sessionId: String): Response<Session>
@GET("session")
suspend fun getByUserId(@Query("userId") userId: String): Response<Session>
@POST("session")
suspend fun createSession(@Body session: Session): Response<Unit>
@DELETE("session")
suspend fun deleteSession(@Body session: Session): Response<Unit>
@POST("session/{sessionId}/aufgaben")
suspend fun setAufgaben(
@Path("sessionId") sessionId: String,
@Body list: AufgabenList
): Response<Unit>
@GET("session/{sessionId}/aufgaben/next")
suspend fun getNextAufgabe(@Path("sessionId") sessionId: String): Response<AufgabeAnzeige>
@POST("session/{sessionId}/mitspieler")
suspend fun addMitspieler(
@Path("sessionId") sessionId: String,
@Body mitspieler: Mitspieler
): Response<Unit>
@GET("session/{sessionId}/finisher")
suspend fun getFinisher(@Path("sessionId") sessionId: String): Response<List<AufgabeAnzeige>>
@POST("session/{sessionId}/backToLevel5")
suspend fun backToLevel5(@Path("sessionId") sessionId: String): Response<Unit>
}

View File

@@ -0,0 +1,71 @@
package de.oaa.xxx.app.data.api
import de.oaa.xxx.app.data.model.ConversationSummary
import de.oaa.xxx.app.data.model.CountResponse
import de.oaa.xxx.app.data.model.FriendshipDto
import de.oaa.xxx.app.data.model.MessageDto
import de.oaa.xxx.app.data.model.PinnwandEintragDto
import de.oaa.xxx.app.data.model.User
import de.oaa.xxx.app.data.model.UserProfile
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface SocialApi {
// --- User profiles ---
@GET("social/users/{userId}")
suspend fun getUserProfile(@Path("userId") userId: String): Response<UserProfile>
@GET("social/users/search")
suspend fun searchUsers(@Query("q") query: String): Response<List<User>>
// --- Friends ---
@POST("social/friends/request")
suspend fun sendFriendRequest(@Body body: Map<String, String>): Response<Unit>
@POST("social/friends/accept")
suspend fun acceptFriendRequest(@Body body: Map<String, String>): Response<Unit>
@DELETE("social/friends/reject")
suspend fun rejectFriend(@Body body: Map<String, String>): Response<Unit>
@GET("social/friends")
suspend fun getFriends(): Response<List<FriendshipDto>>
@GET("social/friends/pending")
suspend fun getPendingRequests(): Response<List<FriendshipDto>>
@GET("social/friends/pending/count")
suspend fun getPendingCount(): Response<CountResponse>
// --- Messages ---
@POST("social/messages")
suspend fun sendMessage(@Body body: Map<String, String>): Response<Unit>
@GET("social/messages")
suspend fun getConversations(): Response<List<ConversationSummary>>
@GET("social/messages/unread/count")
suspend fun getUnreadCount(): Response<CountResponse>
@GET("social/messages/{partnerId}")
suspend fun getConversation(@Path("partnerId") partnerId: String): Response<List<MessageDto>>
// --- Pinnwand ---
@GET("social/pinnwand/{userId}")
suspend fun getPinnwand(@Path("userId") userId: String): Response<List<PinnwandEintragDto>>
@POST("social/pinnwand/{userId}")
suspend fun createPinnwandEintrag(
@Path("userId") userId: String,
@Body body: Map<String, String>
): Response<Unit>
@DELETE("social/pinnwand/{eintragId}")
suspend fun deletePinnwandEintrag(@Path("eintragId") eintragId: String): Response<Unit>
}

View File

@@ -0,0 +1,40 @@
package de.oaa.xxx.app.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "xxx_session")
class TokenStore(private val context: Context) {
companion object {
private val JWT_KEY = stringPreferencesKey("jwt_token")
private val USER_ID_KEY = stringPreferencesKey("user_id")
private val USER_NAME_KEY = stringPreferencesKey("user_name")
}
val jwtToken: Flow<String?> = context.dataStore.data.map { it[JWT_KEY] }
val userId: Flow<String?> = context.dataStore.data.map { it[USER_ID_KEY] }
val userName: Flow<String?> = context.dataStore.data.map { it[USER_NAME_KEY] }
suspend fun saveToken(token: String) {
context.dataStore.edit { it[JWT_KEY] = token }
}
suspend fun saveUser(userId: String, name: String) {
context.dataStore.edit {
it[USER_ID_KEY] = userId
it[USER_NAME_KEY] = name
}
}
suspend fun clear() {
context.dataStore.edit { it.clear() }
}
}

View File

@@ -0,0 +1,269 @@
package de.oaa.xxx.app.data.model
import com.google.gson.JsonObject
// ---- User ----
data class User(
val userId: String? = null,
val name: String? = null,
val email: String? = null,
val profilePicture: String? = null,
val alter: Int? = null,
val groesse: Int? = null,
val gewicht: Int? = null,
val geschlecht: String? = null,
val neigung: String? = null,
val beziehungsstatus: String? = null,
val beschreibung: String? = null
)
data class UserProfile(
val user: User? = null,
val friendshipStatus: String? = null
)
// ---- Aufgaben ----
data class AufgabenGruppe(
val gruppenId: String? = null,
val name: String? = null,
val beschreibung: String? = null,
val userId: String? = null,
val privateGruppe: Boolean = false,
val bild: String? = null,
val von: String? = null,
val aufgaben: List<Aufgabe> = emptyList(),
val strafen: List<Strafe> = emptyList(),
val sperren: List<Sperre> = emptyList(),
val finisher: List<Finisher> = emptyList()
)
data class AufgabenGruppeDisplay(
val gruppenId: String? = null,
val name: String? = null,
val beschreibung: String? = null,
val userId: String? = null,
val privateGruppe: Boolean = false,
val bild: String? = null,
val von: String? = null
)
data class Aufgabe(
val aufgabeId: String? = null,
val kurzText: String? = null,
val text: String? = null,
val level: Int? = null,
val sekundenVon: Int? = null,
val sekundenBis: Int? = null,
val benoetigtAktiv: List<String>? = null,
val benoetigtPassiv: List<String>? = null,
val benoetigteToys: List<String>? = null
)
data class Strafe(
val strafeId: String? = null,
val kurzText: String? = null,
val text: String? = null,
val level: Int? = null,
val sekundenVon: Int? = null,
val sekundenBis: Int? = null
)
data class Sperre(
val sperreId: String? = null,
val kurzText: String? = null,
val text: String? = null,
val releaseText: String? = null,
val minutenVon: Int? = null,
val minutenBis: Int? = null,
val sperreFuer: String? = null
)
data class Finisher(
val finisherId: String? = null,
val kurzText: String? = null,
val text: String? = null,
val geschlecht: String? = null,
val benoetigtAktiv: List<String>? = null,
val benoetigtPassiv: List<String>? = null
)
data class Toy(
val toyId: String? = null,
val name: String? = null,
val beschreibung: String? = null,
val bild: String? = null,
val userId: String? = null
)
data class Favorit(
val favoritId: String? = null,
val gruppenId: String? = null,
val userId: String? = null
)
data class GruppenAbo(
val aboId: String? = null,
val gruppenId: String? = null,
val userId: String? = null
)
// ---- Session ----
data class Session(
val sessionId: String? = null,
val userId: String? = null,
val wahrscheinlichkeitSperre: Int? = null,
val wahrscheinlichkeitStrafe: Int? = null,
val aufgabenProLevel: Int? = null,
val zeitfaktorZeitstrafen: Double? = null,
val level: Int? = null,
val aufgabenAufAktuellemLevel: Int? = null,
val startZeit: String? = null,
val letzteAktivitaet: String? = null
)
data class Mitspieler(
val mitspielerId: String? = null,
val name: String? = null,
val geschlecht: String? = null,
val rollen: List<String>? = null,
val spieltMit: List<String>? = null,
val verfuegbareWerkzeuge: List<String>? = null
)
data class AufgabeAnzeige(
val nameAktiverMitspieler: String? = null,
val aufgabeText: String? = null,
val timer: Int? = null,
val callback: JsonObject? = null,
val level: Int? = null
)
data class AufgabenList(
val aufgaben: List<AufgabenGruppenRef> = emptyList()
)
data class AufgabenGruppenRef(
val gruppenId: String,
val aktiv: Boolean = true,
val passiv: Boolean = true
)
// ---- Social ----
data class FriendshipDto(
val friendshipId: String? = null,
val senderId: String? = null,
val receiverId: String? = null,
val senderName: String? = null,
val receiverName: String? = null,
val status: String? = null,
val createdAt: String? = null
)
data class MessageDto(
val messageId: String? = null,
val senderId: String? = null,
val receiverId: String? = null,
val text: String? = null,
val sentAt: String? = null,
val readAt: String? = null
)
data class ConversationSummary(
val partnerId: String? = null,
val partnerName: String? = null,
val lastMessage: String? = null,
val lastMessageAt: String? = null,
val unreadCount: Int? = null
)
data class PinnwandEintragDto(
val eintragId: String? = null,
val profilUserId: String? = null,
val authorId: String? = null,
val authorName: String? = null,
val text: String? = null,
val createdAt: String? = null
)
// ---- Community Gruppen ----
data class GruppeDto(
val gruppeId: String? = null,
val name: String? = null,
val beschreibung: String? = null,
val bild: String? = null,
val isPrivate: Boolean = false,
val createdAt: String? = null,
val createdByUserId: String? = null,
val memberCount: Int? = null,
val userRole: String? = null
)
data class GruppenbeitragDto(
val beitragId: String? = null,
val gruppeId: String? = null,
val authorId: String? = null,
val authorName: String? = null,
val text: String? = null,
val beitragTyp: String? = null,
val createdAt: String? = null,
val likeCount: Int? = null,
val userHasLiked: Boolean = false,
val umfrageOptionen: List<UmfrageOptionDto>? = null
)
data class UmfrageOptionDto(
val optionId: String? = null,
val text: String? = null,
val stimmenAnzahl: Int? = null,
val userHasVoted: Boolean = false
)
data class BeitrittsanfrageDto(
val anfrageId: String? = null,
val gruppeId: String? = null,
val userId: String? = null,
val userName: String? = null,
val nachricht: String? = null,
val angefragtAt: String? = null,
val status: String? = null
)
// ---- Feed ----
data class FeedItemDto(
val postId: String? = null,
val authorId: String? = null,
val authorName: String? = null,
val text: String? = null,
val beitragTyp: String? = null,
val isPublic: Boolean = false,
val createdAt: String? = null,
val likeCount: Int? = null,
val userHasLiked: Boolean = false,
val umfrageOptionen: List<UmfrageOptionDto>? = null
)
data class FeedPostRequest(
val text: String,
val beitragTyp: String = "TEXT",
val isPublic: Boolean = false,
val umfrageOptionen: List<String>? = null,
val multiChoice: Boolean = false
)
// ---- Registration ----
data class RegistrationRequest(
val name: String,
val email: String,
val password: String
)
// ---- Count responses ----
data class CountResponse(val count: Int)
// ---- Page responses ----
data class PageResponse<T>(
val content: List<T> = emptyList(),
val totalElements: Long = 0,
val totalPages: Int = 0,
val number: Int = 0
)

View File

@@ -0,0 +1,22 @@
package de.oaa.xxx.app.ui.navigation
object Routes {
const val LOGIN = "login"
const val REGISTER = "register"
const val HOME = "home"
const val AUFGABEN = "aufgaben"
const val AUFGABEN_DETAIL = "aufgaben/{gruppenId}"
const val TOYS = "toys"
const val SESSION_SETUP = "session/setup"
const val SESSION_PLAYERS = "session/players/{sessionId}"
const val SESSION_TASKS = "session/tasks/{sessionId}"
const val SESSION_INGAME = "session/ingame/{sessionId}"
const val PROFILE = "profile"
const val FRIENDS = "friends"
const val MESSAGES = "messages"
const val CONVERSATION = "messages/{partnerId}"
const val USER_PROFILE = "user/{userId}"
const val GRUPPEN = "gruppen"
const val GRUPPE_DETAIL = "gruppen/{gruppeId}"
const val FEED = "feed"
}

View File

@@ -0,0 +1,133 @@
package de.oaa.xxx.app.ui.screens.aufgaben
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.AufgabenGruppeDisplay
import de.oaa.xxx.app.ui.viewmodel.AufgabenViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AufgabenScreen(
viewModel: AufgabenViewModel,
onNavigateBack: () -> Unit,
onGruppeClick: (String) -> Unit
) {
val userGruppen by viewModel.userGruppen.collectAsState()
val systemGruppen by viewModel.systemGruppen.collectAsState()
val subscriptions by viewModel.subscriptions.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Eigene", "System", "Abonniert")
LaunchedEffect(Unit) {
viewModel.loadUserGruppen()
viewModel.loadSystemGruppen()
viewModel.loadSubscriptions()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Aufgabengruppen") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
val currentList = when (selectedTab) {
0 -> userGruppen
1 -> systemGruppen
else -> subscriptions
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(currentList) { gruppe ->
GruppeCard(
gruppe = gruppe,
showCopy = selectedTab == 1,
onClick = { gruppe.gruppenId?.let(onGruppeClick) },
onCopy = { gruppe.gruppenId?.let { viewModel.copyGruppe(it) {} } }
)
}
}
}
}
}
}
@Composable
fun GruppeCard(
gruppe: AufgabenGruppeDisplay,
showCopy: Boolean,
onClick: () -> Unit,
onCopy: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = gruppe.name ?: "(Unbekannt)",
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
gruppe.beschreibung?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 2
)
}
gruppe.von?.takeIf { it.isNotBlank() }?.let {
Text(text = "von: $it", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary)
}
}
if (showCopy) {
IconButton(onClick = onCopy) {
Icon(Icons.Default.ContentCopy, contentDescription = "Kopieren", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
}

View File

@@ -0,0 +1,107 @@
package de.oaa.xxx.app.ui.screens.auth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.ui.viewmodel.AuthState
import de.oaa.xxx.app.ui.viewmodel.AuthViewModel
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit
) {
val state by viewModel.state.collectAsState()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
LaunchedEffect(state) {
if (state is AuthState.LoggedIn) onLoginSuccess()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "XXX The Game",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Anmelden",
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Passwort") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(24.dp))
if (state is AuthState.Error) {
Text(
text = (state as AuthState.Error).message,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 16.dp)
)
}
Button(
onClick = { viewModel.login(email, password) },
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = state !is AuthState.Loading && email.isNotBlank() && password.isNotBlank()
) {
if (state is AuthState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Anmelden", fontSize = 16.sp)
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = onNavigateToRegister) {
Text("Noch kein Konto? Registrieren")
}
}
}

View File

@@ -0,0 +1,137 @@
package de.oaa.xxx.app.ui.screens.auth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.ui.viewmodel.AuthState
import de.oaa.xxx.app.ui.viewmodel.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
viewModel: AuthViewModel,
onNavigateBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordRepeat by remember { mutableStateOf("") }
var message by remember { mutableStateOf<String?>(null) }
LaunchedEffect(state) {
if (state is AuthState.Error) {
message = (state as AuthState.Error).message
viewModel.clearError()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Registrieren") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Konto erstellen",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Benutzername") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Passwort") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = passwordRepeat,
onValueChange = { passwordRepeat = it },
label = { Text("Passwort wiederholen") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = password.isNotBlank() && passwordRepeat.isNotBlank() && password != passwordRepeat
)
Spacer(modifier = Modifier.height(24.dp))
message?.let {
Text(
text = it,
color = if (it.contains("erfolgreich")) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 16.dp)
)
}
Button(
onClick = {
if (password == passwordRepeat) {
viewModel.register(name, email, password)
} else {
message = "Passwörter stimmen nicht überein"
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = state !is AuthState.Loading
&& name.isNotBlank() && email.isNotBlank()
&& password.isNotBlank() && passwordRepeat.isNotBlank()
) {
if (state is AuthState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text("Registrieren", fontSize = 16.sp)
}
}
}
}
}

View File

@@ -0,0 +1,174 @@
package de.oaa.xxx.app.ui.screens.feed
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.FeedItemDto
import de.oaa.xxx.app.ui.viewmodel.FeedViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedScreen(
viewModel: FeedViewModel,
onNavigateBack: () -> Unit
) {
val myFeed by viewModel.myFeed.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var selectedTab by remember { mutableIntStateOf(0) }
var newPostText by remember { mutableStateOf("") }
var postPublic by remember { mutableStateOf(false) }
var showComposer by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { viewModel.loadMyFeed() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Feed") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
TextButton(onClick = { showComposer = !showComposer }) {
Text(if (showComposer) "Abbrechen" else "+ Post")
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (showComposer) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = newPostText,
onValueChange = { newPostText = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Was möchtest du teilen?") },
minLines = 3
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = postPublic, onCheckedChange = { postPublic = it })
Text("Öffentlich")
}
Button(
onClick = {
if (newPostText.isNotBlank()) {
viewModel.createPost(newPostText, postPublic)
newPostText = ""
showComposer = false
}
}
) {
Icon(Icons.Default.Send, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Posten")
}
}
}
}
}
TabRow(selectedTabIndex = selectedTab) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Mein Feed") })
Tab(selected = selectedTab == 1, onClick = {
selectedTab = 1
viewModel.loadPublicFeed()
}, text = { Text("Öffentlich") })
}
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(myFeed) { post ->
FeedPostCard(
post = post,
onLike = { viewModel.toggleLike(post.postId ?: "") }
)
}
}
}
}
}
}
@Composable
fun FeedPostCard(post: FeedItemDto, onLike: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(post.authorName ?: "(Unbekannt)", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f))
if (post.isPublic) {
Text("Öffentlich", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
}
}
Text(post.text ?: "", fontSize = 15.sp, lineHeight = 22.sp)
// Poll options
post.umfrageOptionen?.forEach { option ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
LinearProgressIndicator(
progress = { (option.stimmenAnzahl ?: 0).toFloat() / maxOf(1f, (post.umfrageOptionen.sumOf { it.stimmenAnzahl ?: 0 }).toFloat()) },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(option.text ?: "", fontSize = 13.sp)
Text(" (${option.stimmenAnzahl ?: 0})", fontSize = 11.sp, color = MaterialTheme.colorScheme.primary)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(post.createdAt ?: "", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onLike, modifier = Modifier.size(32.dp)) {
Icon(
if (post.userHasLiked) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Like",
tint = if (post.userHasLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
}
Text("${post.likeCount ?: 0}", fontSize = 13.sp)
}
}
}
}
}

View File

@@ -0,0 +1,231 @@
package de.oaa.xxx.app.ui.screens.gruppen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.oaa.xxx.app.data.api.ApiClient
import de.oaa.xxx.app.data.model.GruppeDto
import de.oaa.xxx.app.data.model.GruppenbeitragDto
import de.oaa.xxx.app.ui.viewmodel.FeedViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GruppenScreen(
currentUserId: String,
onNavigateBack: () -> Unit
) {
var gruppen by remember { mutableStateOf<List<GruppeDto>>(emptyList()) }
var searchResults by remember { mutableStateOf<List<GruppeDto>>(emptyList()) }
var selectedGruppe by remember { mutableStateOf<GruppeDto?>(null) }
var searchQuery by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
isLoading = true
try {
val resp = ApiClient.gruppenApi.getMine()
if (resp.isSuccessful) gruppen = resp.body() ?: emptyList()
} catch (e: Exception) {
} finally {
isLoading = false
}
}
if (selectedGruppe != null) {
GruppeDetailScreen(
gruppe = selectedGruppe!!,
currentUserId = currentUserId,
onNavigateBack = { selectedGruppe = null }
)
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Gruppen") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
IconButton(onClick = { showSearch = !showSearch }) {
Icon(Icons.Default.Search, contentDescription = "Suchen")
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (showSearch) {
OutlinedTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
if (it.length >= 2) {
scope.launch {
val resp = ApiClient.gruppenApi.search(it)
if (resp.isSuccessful) searchResults = resp.body() ?: emptyList()
}
}
},
label = { Text("Gruppen suchen...") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true
)
}
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
val displayList = if (showSearch && searchQuery.isNotBlank()) searchResults else gruppen
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(displayList) { gruppe ->
GruppeCard(gruppe = gruppe, onClick = { selectedGruppe = gruppe })
}
}
}
}
}
}
@Composable
fun GruppeCard(gruppe: GruppeDto, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(gruppe.name ?: "(Unbekannt)", fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
if (gruppe.isPrivate) {
Text("Privat", fontSize = 11.sp, color = MaterialTheme.colorScheme.primary)
}
}
gruppe.beschreibung?.takeIf { it.isNotBlank() }?.let {
Text(it, fontSize = 13.sp, maxLines = 2, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
}
gruppe.memberCount?.let {
Text("$it Mitglieder", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GruppeDetailScreen(
gruppe: GruppeDto,
currentUserId: String,
onNavigateBack: () -> Unit
) {
var beitraege by remember { mutableStateOf<List<GruppenbeitragDto>>(emptyList()) }
var newPostText by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(gruppe.gruppeId) {
gruppe.gruppeId?.let { id ->
isLoading = true
try {
val resp = ApiClient.gruppenApi.getBeitraege(id)
if (resp.isSuccessful) beitraege = resp.body() ?: emptyList()
} catch (e: Exception) {
} finally {
isLoading = false
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(gruppe.name ?: "Gruppe") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = newPostText,
onValueChange = { newPostText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Neuer Beitrag...") },
singleLine = false,
maxLines = 3
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = {
if (newPostText.isNotBlank()) {
scope.launch {
gruppe.gruppeId?.let { id ->
val beitrag = GruppenbeitragDto(text = newPostText, beitragTyp = "TEXT")
val resp = ApiClient.gruppenApi.createBeitrag(id, beitrag)
if (resp.isSuccessful) {
newPostText = ""
val refresh = ApiClient.gruppenApi.getBeitraege(id)
if (refresh.isSuccessful) beitraege = refresh.body() ?: emptyList()
}
}
}
}
}) {
Icon(Icons.Default.Add, contentDescription = "Beitrag erstellen", tint = MaterialTheme.colorScheme.primary)
}
}
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(beitraege) { beitrag ->
BeitragCard(beitrag = beitrag, currentUserId = currentUserId)
}
}
}
}
}
@Composable
fun BeitragCard(beitrag: GruppenbeitragDto, currentUserId: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(beitrag.authorName ?: "(Unbekannt)", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Text(beitrag.text ?: "", fontSize = 15.sp)
Text(beitrag.createdAt ?: "", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
}
}
}

View File

@@ -0,0 +1,107 @@
package de.oaa.xxx.app.ui.screens.home
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.User
data class HomeMenuItem(
val title: String,
val icon: ImageVector,
val route: String
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
user: User,
onNavigate: (String) -> Unit,
onLogout: () -> Unit
) {
val menuItems = listOf(
HomeMenuItem("BDSM Session", Icons.Default.PlayArrow, "session/setup"),
HomeMenuItem("Aufgaben", Icons.Default.List, "aufgaben"),
HomeMenuItem("Spielzeug", Icons.Default.Star, "toys"),
HomeMenuItem("Feed", Icons.Default.Home, "feed"),
HomeMenuItem("Gruppen", Icons.Default.Group, "gruppen"),
HomeMenuItem("Freunde", Icons.Default.People, "friends"),
HomeMenuItem("Nachrichten", Icons.Default.Message, "messages"),
HomeMenuItem("Profil", Icons.Default.Person, "profile"),
)
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("XXX The Game", fontSize = 18.sp, fontWeight = FontWeight.Bold)
Text("Hallo, ${user.name ?: "Spieler"}!", fontSize = 12.sp)
}
},
actions = {
IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = "Abmelden")
}
}
)
}
) { padding ->
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(menuItems) { item ->
MenuCard(item = item, onClick = { onNavigate(item.route) })
}
}
}
}
@Composable
fun MenuCard(item: HomeMenuItem, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = item.icon,
contentDescription = item.title,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = item.title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}

View File

@@ -0,0 +1,155 @@
package de.oaa.xxx.app.ui.screens.profile
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.api.ApiClient
import de.oaa.xxx.app.data.model.User
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
currentUser: User,
onNavigateBack: () -> Unit,
onUserUpdated: (User) -> Unit
) {
var user by remember { mutableStateOf(currentUser) }
var editing by remember { mutableStateOf(false) }
var name by remember { mutableStateOf(currentUser.name ?: "") }
var beschreibung by remember { mutableStateOf(currentUser.beschreibung ?: "") }
var alter by remember { mutableStateOf(currentUser.alter?.toString() ?: "") }
var groesse by remember { mutableStateOf(currentUser.groesse?.toString() ?: "") }
var gewicht by remember { mutableStateOf(currentUser.gewicht?.toString() ?: "") }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Profil") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
IconButton(onClick = { editing = !editing }) {
Icon(Icons.Default.Edit, contentDescription = "Bearbeiten", tint = if (editing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Avatar placeholder
Surface(
modifier = Modifier.size(100.dp),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = (user.name?.firstOrNull()?.uppercaseChar() ?: 'U').toString(),
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
if (!editing) {
// View mode
Text(user.name ?: "", fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(user.email ?: "", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
ProfileRow("Alter", user.alter?.toString() ?: "-")
ProfileRow("Größe", user.groesse?.let { "$it cm" } ?: "-")
ProfileRow("Gewicht", user.gewicht?.let { "$it kg" } ?: "-")
ProfileRow("Geschlecht", user.geschlecht ?: "-")
ProfileRow("Neigung", user.neigung ?: "-")
ProfileRow("Beziehungsstatus", user.beziehungsstatus ?: "-")
user.beschreibung?.takeIf { it.isNotBlank() }?.let {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("Über mich", fontWeight = FontWeight.Bold)
Text(it, fontSize = 14.sp)
}
} else {
// Edit mode
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Benutzername") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = alter, onValueChange = { alter = it }, label = { Text("Alter") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = groesse, onValueChange = { groesse = it }, label = { Text("Größe (cm)") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = gewicht, onValueChange = { gewicht = it }, label = { Text("Gewicht (kg)") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = beschreibung, onValueChange = { beschreibung = it }, label = { Text("Über mich") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
scope.launch {
isLoading = true
try {
val updated = user.copy(
name = name,
alter = alter.toIntOrNull(),
groesse = groesse.toIntOrNull(),
gewicht = gewicht.toIntOrNull(),
beschreibung = beschreibung
)
// Save profile and name changes
val profileResp = ApiClient.authApi.me()
if (profileResp.isSuccessful) {
user = updated
onUserUpdated(updated)
editing = false
}
} catch (e: Exception) {
error = e.message
} finally {
isLoading = false
}
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = !isLoading
) {
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp))
else Text("Speichern")
}
}
}
}
}
@Composable
fun ProfileRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), fontSize = 14.sp)
Text(value, fontWeight = FontWeight.Medium, fontSize = 14.sp)
}
}

View File

@@ -0,0 +1,223 @@
package de.oaa.xxx.app.ui.screens.session
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.NavigateNext
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.AufgabeAnzeige
import de.oaa.xxx.app.data.model.Session
import de.oaa.xxx.app.ui.viewmodel.SessionViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionInGameScreen(
viewModel: SessionViewModel,
sessionId: String,
onSessionEnd: () -> Unit,
onNavigateBack: () -> Unit
) {
val currentAufgabe by viewModel.currentAufgabe.collectAsState()
val finisherList by viewModel.finisherList.collectAsState()
val session by viewModel.session.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
var showEndDialog by remember { mutableStateOf(false) }
var isFinished by remember { mutableStateOf(false) }
LaunchedEffect(sessionId) {
viewModel.getNextAufgabe(sessionId)
}
LaunchedEffect(finisherList) {
if (finisherList.isNotEmpty()) isFinished = true
}
if (showEndDialog) {
AlertDialog(
onDismissRequest = { showEndDialog = false },
title = { Text("Session beenden?") },
text = { Text("Soll die aktuelle Session wirklich beendet werden?") },
confirmButton = {
TextButton(onClick = {
session?.let { viewModel.deleteSession(it) { onSessionEnd() } }
showEndDialog = false
}) { Text("Beenden", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showEndDialog = false }) { Text("Abbrechen") }
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Level ${currentAufgabe?.level ?: session?.level ?: 1}")
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
IconButton(onClick = { showEndDialog = true }) {
Icon(Icons.Default.Stop, contentDescription = "Session beenden", tint = MaterialTheme.colorScheme.error)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (isFinished) {
FinisherScreen(finisherList = finisherList, onEnd = {
session?.let { viewModel.deleteSession(it) { onSessionEnd() } }
})
} else {
AnimatedContent(targetState = currentAufgabe, label = "aufgabe") { aufgabe ->
if (aufgabe != null) {
AufgabeDisplay(
aufgabe = aufgabe,
onNext = { viewModel.getNextAufgabe(sessionId) }
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Warte auf nächste Aufgabe...", color = MaterialTheme.colorScheme.onBackground)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.getNextAufgabe(sessionId) }) {
Text("Nächste Aufgabe")
}
}
}
}
}
}
error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 16.dp))
}
}
}
}
@Composable
fun AufgabeDisplay(aufgabe: AufgabeAnzeige, onNext: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
aufgabe.nameAktiverMitspieler?.let {
Text(
text = it,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Text(
text = aufgabe.aufgabeText ?: "",
modifier = Modifier.padding(24.dp),
fontSize = 18.sp,
textAlign = TextAlign.Center,
lineHeight = 28.sp
)
}
aufgabe.timer?.let { timer ->
if (timer > 0) {
Text(
text = "${timer / 60}:${String.format("%02d", timer % 60)} min",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Icon(Icons.Default.NavigateNext, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Nächste Aufgabe", fontSize = 16.sp)
}
}
}
@Composable
fun FinisherScreen(finisherList: List<AufgabeAnzeige>, onEnd: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Session abgeschlossen!",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text("Finisher-Aufgaben:", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(finisherList) { finisher ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
finisher.nameAktiverMitspieler?.let {
Text(text = it, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
}
Text(text = finisher.aufgabeText ?: "", fontSize = 15.sp)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onEnd, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Text("Session beenden", fontSize = 16.sp)
}
}
}

View File

@@ -0,0 +1,149 @@
package de.oaa.xxx.app.ui.screens.session
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.User
import de.oaa.xxx.app.ui.viewmodel.SessionViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionSetupScreen(
viewModel: SessionViewModel,
currentUser: User,
onNavigateBack: () -> Unit,
onSessionCreated: (String) -> Unit
) {
val session by viewModel.session.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
var aufgabenProLevel by remember { mutableIntStateOf(5) }
var wahrscheinlichkeitSperre by remember { mutableIntStateOf(10) }
var wahrscheinlichkeitStrafe by remember { mutableIntStateOf(10) }
var zeitfaktor by remember { mutableStateOf(1.0) }
LaunchedEffect(currentUser.userId) {
currentUser.userId?.let { viewModel.loadSession(it) }
}
LaunchedEffect(session) {
session?.sessionId?.let { onSessionCreated(it) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("BDSM Session") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text("Session konfigurieren", fontSize = 20.sp, fontWeight = FontWeight.Bold)
SliderSetting(
label = "Aufgaben pro Level",
value = aufgabenProLevel.toFloat(),
onValueChange = { aufgabenProLevel = it.toInt() },
valueRange = 1f..20f,
steps = 18,
displayValue = "$aufgabenProLevel"
)
SliderSetting(
label = "Wahrscheinlichkeit Sperre",
value = wahrscheinlichkeitSperre.toFloat(),
onValueChange = { wahrscheinlichkeitSperre = it.toInt() },
valueRange = 0f..100f,
steps = 99,
displayValue = "$wahrscheinlichkeitSperre%"
)
SliderSetting(
label = "Wahrscheinlichkeit Strafe",
value = wahrscheinlichkeitStrafe.toFloat(),
onValueChange = { wahrscheinlichkeitStrafe = it.toInt() },
valueRange = 0f..100f,
steps = 99,
displayValue = "$wahrscheinlichkeitStrafe%"
)
SliderSetting(
label = "Zeitfaktor Strafen",
value = zeitfaktor.toFloat(),
onValueChange = { zeitfaktor = it.toDouble() },
valueRange = 0.1f..5f,
steps = 48,
displayValue = String.format("%.1fx", zeitfaktor)
)
error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
viewModel.createSession(
aufgabenProLevel,
wahrscheinlichkeitSperre,
wahrscheinlichkeitStrafe,
zeitfaktor
) {}
currentUser.userId?.let { viewModel.loadSession(it) }
},
modifier = Modifier.fillMaxWidth().height(52.dp),
enabled = !isLoading
) {
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp))
else Text("Session starten", fontSize = 16.sp)
}
}
}
}
@Composable
fun SliderSetting(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
displayValue: String
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label, fontSize = 14.sp)
Text(displayValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
}
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@@ -0,0 +1,177 @@
package de.oaa.xxx.app.ui.screens.social
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.FriendshipDto
import de.oaa.xxx.app.data.model.User
import de.oaa.xxx.app.ui.viewmodel.SocialViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FriendsScreen(
viewModel: SocialViewModel,
currentUserId: String,
onNavigateBack: () -> Unit,
onOpenMessages: (String) -> Unit
) {
val friends by viewModel.friends.collectAsState()
val pending by viewModel.pending.collectAsState()
val searchResults by viewModel.searchResults.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var selectedTab by remember { mutableIntStateOf(0) }
var searchQuery by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { viewModel.loadFriends() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Freunde") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
IconButton(onClick = { showSearch = !showSearch }) {
Icon(Icons.Default.Search, contentDescription = "Suchen")
}
}
)
}
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (showSearch) {
OutlinedTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
if (it.length >= 2) viewModel.searchUsers(it)
},
label = { Text("Benutzer suchen...") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
trailingIcon = {
IconButton(onClick = { viewModel.searchUsers(searchQuery) }) {
Icon(Icons.Default.Search, contentDescription = "Suchen")
}
},
singleLine = true
)
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(searchResults) { user ->
SearchUserCard(user = user, onSendRequest = {
user.userId?.let { viewModel.sendFriendRequest(it) }
})
}
}
} else {
TabRow(selectedTabIndex = selectedTab) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Freunde (${friends.size})") })
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Anfragen (${pending.size})") })
}
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (selectedTab == 0) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(friends) { friendship ->
FriendCard(
friendship = friendship,
currentUserId = currentUserId,
onMessage = { partnerId -> onOpenMessages(partnerId) },
onRemove = { viewModel.rejectRequest(friendship.friendshipId ?: "") }
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(pending) { request ->
PendingRequestCard(
request = request,
currentUserId = currentUserId,
onAccept = { viewModel.acceptRequest(request.friendshipId ?: "") },
onReject = { viewModel.rejectRequest(request.friendshipId ?: "") }
)
}
}
}
}
}
}
}
@Composable
fun SearchUserCard(user: User, onSendRequest: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(user.name ?: "(Unbekannt)", modifier = Modifier.weight(1f), fontWeight = FontWeight.Medium)
IconButton(onClick = onSendRequest) {
Icon(Icons.Default.PersonAdd, contentDescription = "Anfrage senden", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
@Composable
fun FriendCard(friendship: FriendshipDto, currentUserId: String, onMessage: (String) -> Unit, onRemove: () -> Unit) {
val partnerName = if (friendship.senderId == currentUserId) friendship.receiverName else friendship.senderName
val partnerId = if (friendship.senderId == currentUserId) friendship.receiverId else friendship.senderId
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(partnerName ?: "(Unbekannt)", modifier = Modifier.weight(1f), fontWeight = FontWeight.Medium)
IconButton(onClick = { partnerId?.let(onMessage) }) {
Icon(Icons.Default.Check, contentDescription = "Nachricht", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
@Composable
fun PendingRequestCard(request: FriendshipDto, currentUserId: String, onAccept: () -> Unit, onReject: () -> Unit) {
val name = if (request.senderId == currentUserId) request.receiverName else request.senderName
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(name ?: "(Unbekannt)", fontWeight = FontWeight.Medium)
val direction = if (request.senderId == currentUserId) "Gesendet" else "Empfangen"
Text(direction, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
}
if (request.senderId != currentUserId) {
IconButton(onClick = onAccept) {
Icon(Icons.Default.Check, contentDescription = "Annehmen", tint = MaterialTheme.colorScheme.primary)
}
}
IconButton(onClick = onReject) {
Icon(Icons.Default.Close, contentDescription = "Ablehnen", tint = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -0,0 +1,180 @@
package de.oaa.xxx.app.ui.screens.social
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.oaa.xxx.app.data.model.ConversationSummary
import de.oaa.xxx.app.ui.viewmodel.SocialViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessagesScreen(
viewModel: SocialViewModel,
onNavigateBack: () -> Unit,
onOpenConversation: (String) -> Unit
) {
val conversations by viewModel.conversations.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
LaunchedEffect(Unit) { viewModel.loadConversations() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Nachrichten") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
}
) { padding ->
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (conversations.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text("Noch keine Nachrichten", color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(conversations) { conversation ->
ConversationCard(conversation = conversation, onClick = {
conversation.partnerId?.let(onOpenConversation)
})
}
}
}
}
}
@Composable
fun ConversationCard(conversation: ConversationSummary, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(conversation.partnerName ?: "(Unbekannt)", fontWeight = FontWeight.Bold)
Text(
conversation.lastMessage ?: "",
fontSize = 13.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
if ((conversation.unreadCount ?: 0) > 0) {
Badge { Text("${conversation.unreadCount}") }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationScreen(
viewModel: SocialViewModel,
partnerId: String,
currentUserId: String,
partnerName: String,
onNavigateBack: () -> Unit
) {
val messages by viewModel.messages.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var messageText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(partnerId) { viewModel.loadMessages(partnerId) }
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(partnerName) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück")
}
}
)
},
bottomBar = {
Row(
modifier = Modifier.padding(8.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = messageText,
onValueChange = { messageText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Nachricht...") },
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
if (messageText.isNotBlank()) {
viewModel.sendMessage(partnerId, messageText)
messageText = ""
}
}
) {
Icon(Icons.Default.Send, contentDescription = "Senden", tint = MaterialTheme.colorScheme.primary)
}
}
}
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(messages) { msg ->
val isOwn = msg.senderId == currentUserId
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOwn) Arrangement.End else Arrangement.Start
) {
Card(
modifier = Modifier.widthIn(max = 280.dp),
colors = CardDefaults.cardColors(
containerColor = if (isOwn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
)
) {
Text(
text = msg.text ?: "",
modifier = Modifier.padding(12.dp),
color = if (isOwn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.app.ui.theme
import androidx.compose.ui.graphics.Color
// Web app theme colors
val Primary = Color(0xFFE94560)
val PrimaryDark = Color(0xFFB5264A)
val Background = Color(0xFF1A1A2E)
val Surface = Color(0xFF16213E)
val SurfaceVariant = Color(0xFF0F3460)
val OnBackground = Color(0xFFE0E0E0)
val OnSurface = Color(0xFFCCCCCC)
val OnPrimary = Color(0xFFFFFFFF)
val Error = Color(0xFFCF6679)

View File

@@ -0,0 +1,24 @@
package de.oaa.xxx.app.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = Primary,
onPrimary = OnPrimary,
background = Background,
onBackground = OnBackground,
surface = Surface,
onSurface = OnSurface,
surfaceVariant = SurfaceVariant,
error = Error
)
@Composable
fun XxxGameTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
content = content
)
}

View File

@@ -0,0 +1,145 @@
package de.oaa.xxx.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.oaa.xxx.app.data.api.ApiClient
import de.oaa.xxx.app.data.model.AufgabenGruppe
import de.oaa.xxx.app.data.model.AufgabenGruppeDisplay
import de.oaa.xxx.app.data.model.Toy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AufgabenViewModel : ViewModel() {
private val _userGruppen = MutableStateFlow<List<AufgabenGruppeDisplay>>(emptyList())
val userGruppen: StateFlow<List<AufgabenGruppeDisplay>> = _userGruppen
private val _systemGruppen = MutableStateFlow<List<AufgabenGruppeDisplay>>(emptyList())
val systemGruppen: StateFlow<List<AufgabenGruppeDisplay>> = _systemGruppen
private val _subscriptions = MutableStateFlow<List<AufgabenGruppeDisplay>>(emptyList())
val subscriptions: StateFlow<List<AufgabenGruppeDisplay>> = _subscriptions
private val _selectedGruppe = MutableStateFlow<AufgabenGruppe?>(null)
val selectedGruppe: StateFlow<AufgabenGruppe?> = _selectedGruppe
private val _userToys = MutableStateFlow<List<Toy>>(emptyList())
val userToys: StateFlow<List<Toy>> = _userToys
private val _systemToys = MutableStateFlow<List<Toy>>(emptyList())
val systemToys: StateFlow<List<Toy>> = _systemToys
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
fun loadUserGruppen() {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.aufgabenApi.getUserGruppen()
if (resp.isSuccessful) _userGruppen.value = resp.body() ?: emptyList()
else _error.value = "Fehler beim Laden"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun loadSystemGruppen() {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.aufgabenApi.getSystemGruppen()
if (resp.isSuccessful) _systemGruppen.value = resp.body() ?: emptyList()
else _error.value = "Fehler beim Laden"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun loadSubscriptions() {
viewModelScope.launch {
try {
val resp = ApiClient.aufgabenApi.getAbonnements()
if (resp.isSuccessful) _subscriptions.value = resp.body() ?: emptyList()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun loadGruppe(gruppenId: String) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.aufgabenApi.getGruppe(gruppenId)
if (resp.isSuccessful) _selectedGruppe.value = resp.body()
else _error.value = "Gruppe nicht gefunden"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun loadToys() {
viewModelScope.launch {
try {
val user = ApiClient.aufgabenApi.getUserToys()
val system = ApiClient.aufgabenApi.getSystemToys()
if (user.isSuccessful) _userToys.value = user.body() ?: emptyList()
if (system.isSuccessful) _systemToys.value = system.body() ?: emptyList()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun copyGruppe(gruppenId: String, onSuccess: () -> Unit) {
viewModelScope.launch {
try {
val resp = ApiClient.aufgabenApi.copyGruppe(gruppenId)
if (resp.isSuccessful) {
loadUserGruppen()
onSuccess()
}
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun subscribe(gruppenId: String) {
viewModelScope.launch {
try {
val resp = ApiClient.aufgabenApi.subscribe(gruppenId)
if (resp.isSuccessful) loadSubscriptions()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun unsubscribe(gruppenId: String) {
viewModelScope.launch {
try {
val resp = ApiClient.aufgabenApi.unsubscribe(gruppenId)
if (resp.isSuccessful) loadSubscriptions()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun clearError() { _error.value = null }
}

View File

@@ -0,0 +1,104 @@
package de.oaa.xxx.app.ui.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.oaa.xxx.app.data.api.ApiClient
import de.oaa.xxx.app.data.local.TokenStore
import de.oaa.xxx.app.data.model.RegistrationRequest
import de.oaa.xxx.app.data.model.User
import de.oaa.xxx.app.util.sha256
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
sealed class AuthState {
object Idle : AuthState()
object Loading : AuthState()
data class LoggedIn(val user: User) : AuthState()
object LoggedOut : AuthState()
data class Error(val message: String) : AuthState()
}
class AuthViewModel(application: Application) : AndroidViewModel(application) {
private val tokenStore = TokenStore(application)
private val _state = MutableStateFlow<AuthState>(AuthState.Idle)
val state: StateFlow<AuthState> = _state
init {
checkSession()
}
private fun checkSession() {
viewModelScope.launch {
val token = tokenStore.jwtToken.first()
if (token != null) {
ApiClient.setJwtToken(token)
val resp = runCatching { ApiClient.authApi.me() }.getOrNull()
if (resp?.isSuccessful == true && resp.body() != null) {
_state.value = AuthState.LoggedIn(resp.body()!!)
} else {
tokenStore.clear()
_state.value = AuthState.LoggedOut
}
} else {
_state.value = AuthState.LoggedOut
}
}
}
fun login(email: String, password: String) {
viewModelScope.launch {
_state.value = AuthState.Loading
try {
val hash = sha256(password)
val resp = ApiClient.authApi.login(email, hash)
if (resp.isSuccessful && resp.body() != null) {
val user = resp.body()!!
val jwt = ApiClient.getJwtToken()
if (jwt != null) tokenStore.saveToken(jwt)
tokenStore.saveUser(user.userId ?: "", user.name ?: "")
_state.value = AuthState.LoggedIn(user)
} else {
_state.value = AuthState.Error("E-Mail oder Passwort falsch")
}
} catch (e: Exception) {
_state.value = AuthState.Error(e.message ?: "Verbindungsfehler")
}
}
}
fun register(name: String, email: String, password: String) {
viewModelScope.launch {
_state.value = AuthState.Loading
try {
val hash = sha256(password)
val resp = ApiClient.authApi.register(RegistrationRequest(name, email, hash))
if (resp.isSuccessful) {
_state.value = AuthState.Error("Registrierung erfolgreich! Bitte E-Mail bestätigen.")
} else {
_state.value = AuthState.Error("Registrierung fehlgeschlagen")
}
} catch (e: Exception) {
_state.value = AuthState.Error(e.message ?: "Verbindungsfehler")
}
}
}
fun logout() {
viewModelScope.launch {
runCatching { ApiClient.authApi.logout() }
ApiClient.clearCookies()
tokenStore.clear()
_state.value = AuthState.LoggedOut
}
}
fun clearError() {
if (_state.value is AuthState.Error) {
_state.value = AuthState.LoggedOut
}
}
}

View File

@@ -0,0 +1,91 @@
package de.oaa.xxx.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.oaa.xxx.app.data.api.ApiClient
import de.oaa.xxx.app.data.model.FeedItemDto
import de.oaa.xxx.app.data.model.FeedPostRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class FeedViewModel : ViewModel() {
private val _myFeed = MutableStateFlow<List<FeedItemDto>>(emptyList())
val myFeed: StateFlow<List<FeedItemDto>> = _myFeed
private val _publicFeed = MutableStateFlow<List<FeedItemDto>>(emptyList())
val publicFeed: StateFlow<List<FeedItemDto>> = _publicFeed
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
fun loadMyFeed() {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.feedApi.getMyFeed()
if (resp.isSuccessful) _myFeed.value = resp.body() ?: emptyList()
else _error.value = "Fehler beim Laden des Feeds"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun loadPublicFeed() {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.feedApi.getPublicFeed()
if (resp.isSuccessful) _publicFeed.value = resp.body() ?: emptyList()
else _error.value = "Fehler beim Laden"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun createPost(text: String, isPublic: Boolean) {
viewModelScope.launch {
try {
val resp = ApiClient.feedApi.createPost(FeedPostRequest(text, isPublic = isPublic))
if (resp.isSuccessful) loadMyFeed()
else _error.value = "Post konnte nicht erstellt werden"
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun toggleLike(postId: String) {
viewModelScope.launch {
try {
ApiClient.feedApi.toggleLike(postId)
loadMyFeed()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun deletePost(postId: String) {
viewModelScope.launch {
try {
ApiClient.feedApi.deletePost(postId)
loadMyFeed()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun clearError() { _error.value = null }
}

View File

@@ -0,0 +1,154 @@
package de.oaa.xxx.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.oaa.xxx.app.data.api.ApiClient
import de.oaa.xxx.app.data.model.AufgabeAnzeige
import de.oaa.xxx.app.data.model.AufgabenGruppenRef
import de.oaa.xxx.app.data.model.AufgabenList
import de.oaa.xxx.app.data.model.Mitspieler
import de.oaa.xxx.app.data.model.Session
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SessionViewModel : ViewModel() {
private val _session = MutableStateFlow<Session?>(null)
val session: StateFlow<Session?> = _session
private val _currentAufgabe = MutableStateFlow<AufgabeAnzeige?>(null)
val currentAufgabe: StateFlow<AufgabeAnzeige?> = _currentAufgabe
private val _finisherList = MutableStateFlow<List<AufgabeAnzeige>>(emptyList())
val finisherList: StateFlow<List<AufgabeAnzeige>> = _finisherList
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
fun loadSession(userId: String) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.sessionApi.getByUserId(userId)
_session.value = if (resp.isSuccessful) resp.body() else null
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun createSession(
aufgabenProLevel: Int,
wahrscheinlichkeitSperre: Int,
wahrscheinlichkeitStrafe: Int,
zeitfaktor: Double,
onSuccess: () -> Unit
) {
viewModelScope.launch {
_isLoading.value = true
try {
val s = Session(
aufgabenProLevel = aufgabenProLevel,
wahrscheinlichkeitSperre = wahrscheinlichkeitSperre,
wahrscheinlichkeitStrafe = wahrscheinlichkeitStrafe,
zeitfaktorZeitstrafen = zeitfaktor
)
val resp = ApiClient.sessionApi.createSession(s)
if (resp.isSuccessful) onSuccess()
else _error.value = "Session konnte nicht erstellt werden"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun setAufgaben(sessionId: String, gruppenRefs: List<AufgabenGruppenRef>, onSuccess: () -> Unit) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.sessionApi.setAufgaben(sessionId, AufgabenList(gruppenRefs))
if (resp.isSuccessful) onSuccess()
else _error.value = "Aufgaben konnten nicht gesetzt werden"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun getNextAufgabe(sessionId: String) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.sessionApi.getNextAufgabe(sessionId)
_currentAufgabe.value = if (resp.isSuccessful) resp.body() else null
if (resp.code() == 204) {
// Session finished - load finisher
loadFinisher(sessionId)
}
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun addMitspieler(sessionId: String, mitspieler: Mitspieler, onSuccess: () -> Unit) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.sessionApi.addMitspieler(sessionId, mitspieler)
if (resp.isSuccessful) onSuccess()
else _error.value = "Mitspieler konnte nicht hinzugefügt werden"
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun loadFinisher(sessionId: String) {
viewModelScope.launch {
try {
val resp = ApiClient.sessionApi.getFinisher(sessionId)
_finisherList.value = if (resp.isSuccessful) resp.body() ?: emptyList() else emptyList()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun deleteSession(session: Session, onSuccess: () -> Unit) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.sessionApi.deleteSession(session)
if (resp.isSuccessful) {
_session.value = null
_currentAufgabe.value = null
_finisherList.value = emptyList()
onSuccess()
} else {
_error.value = "Session konnte nicht beendet werden"
}
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun clearError() { _error.value = null }
}

View File

@@ -0,0 +1,154 @@
package de.oaa.xxx.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.oaa.xxx.app.data.api.ApiClient
import de.oaa.xxx.app.data.model.ConversationSummary
import de.oaa.xxx.app.data.model.FriendshipDto
import de.oaa.xxx.app.data.model.MessageDto
import de.oaa.xxx.app.data.model.User
import de.oaa.xxx.app.data.model.UserProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SocialViewModel : ViewModel() {
private val _friends = MutableStateFlow<List<FriendshipDto>>(emptyList())
val friends: StateFlow<List<FriendshipDto>> = _friends
private val _pending = MutableStateFlow<List<FriendshipDto>>(emptyList())
val pending: StateFlow<List<FriendshipDto>> = _pending
private val _searchResults = MutableStateFlow<List<User>>(emptyList())
val searchResults: StateFlow<List<User>> = _searchResults
private val _conversations = MutableStateFlow<List<ConversationSummary>>(emptyList())
val conversations: StateFlow<List<ConversationSummary>> = _conversations
private val _messages = MutableStateFlow<List<MessageDto>>(emptyList())
val messages: StateFlow<List<MessageDto>> = _messages
private val _viewedProfile = MutableStateFlow<UserProfile?>(null)
val viewedProfile: StateFlow<UserProfile?> = _viewedProfile
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
fun loadFriends() {
viewModelScope.launch {
_isLoading.value = true
try {
val f = ApiClient.socialApi.getFriends()
val p = ApiClient.socialApi.getPendingRequests()
if (f.isSuccessful) _friends.value = f.body() ?: emptyList()
if (p.isSuccessful) _pending.value = p.body() ?: emptyList()
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun searchUsers(query: String) {
viewModelScope.launch {
try {
val resp = ApiClient.socialApi.searchUsers(query)
if (resp.isSuccessful) _searchResults.value = resp.body() ?: emptyList()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun sendFriendRequest(userId: String) {
viewModelScope.launch {
try {
ApiClient.socialApi.sendFriendRequest(mapOf("receiverId" to userId))
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun acceptRequest(friendshipId: String) {
viewModelScope.launch {
try {
ApiClient.socialApi.acceptFriendRequest(mapOf("friendshipId" to friendshipId))
loadFriends()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun rejectRequest(friendshipId: String) {
viewModelScope.launch {
try {
ApiClient.socialApi.rejectFriend(mapOf("friendshipId" to friendshipId))
loadFriends()
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun loadConversations() {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.socialApi.getConversations()
if (resp.isSuccessful) _conversations.value = resp.body() ?: emptyList()
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun loadMessages(partnerId: String) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.socialApi.getConversation(partnerId)
if (resp.isSuccessful) _messages.value = resp.body() ?: emptyList()
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun sendMessage(receiverId: String, text: String) {
viewModelScope.launch {
try {
ApiClient.socialApi.sendMessage(mapOf("receiverId" to receiverId, "text" to text))
loadMessages(receiverId)
} catch (e: Exception) {
_error.value = e.message
}
}
}
fun loadUserProfile(userId: String) {
viewModelScope.launch {
_isLoading.value = true
try {
val resp = ApiClient.socialApi.getUserProfile(userId)
if (resp.isSuccessful) _viewedProfile.value = resp.body()
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun clearError() { _error.value = null }
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.app.util
import java.security.MessageDigest
fun sha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(input.toByteArray(Charsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1A1A2E"/>
</shape>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#E94560"
android:pathData="M54,20 C35.2,20 20,35.2 20,54 C20,72.8 35.2,88 54,88 C72.8,88 88,72.8 88,54 C88,35.2 72.8,20 54,20Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M44,42 L44,66 L66,54 Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#E94560</color>
<color name="background">#1A1A2E</color>
<color name="surface">#16213E</color>
<color name="surface_variant">#0F3460</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">XXX The Game</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.XxxGame" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">#1a1a2e</item>
<item name="android:navigationBarColor">#1a1a2e</item>
<item name="android:windowBackground">#1a1a2e</item>
</style>
</resources>