Chastity game angefangen
This commit is contained in:
26
xxxthegame-android/app/src/main/AndroidManifest.xml
Normal file
26
xxxthegame-android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.oaa.xxx.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class XxxApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
7
xxxthegame-android/app/src/main/res/values/colors.xml
Normal file
7
xxxthegame-android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
4
xxxthegame-android/app/src/main/res/values/strings.xml
Normal file
4
xxxthegame-android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">XXX The Game</string>
|
||||
</resources>
|
||||
8
xxxthegame-android/app/src/main/res/values/themes.xml
Normal file
8
xxxthegame-android/app/src/main/res/values/themes.xml
Normal 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>
|
||||
Reference in New Issue
Block a user