Feature: Shared lists & Invite Code invitation mechanism
- Backend:
- Migration 000002: add invite_code column to lists & list_members auto-population
- ListStore: GetLists/GetList check owner_id & list_members; add GetInviteCode & JoinByInviteCode
- HTTP API: add POST /api/lists/{id}/invite & POST /api/lists/join
- Android App:
- DTOs & MitbringslApi: add JoinListRequestDto, InviteCodeResponseDto & endpoints
- ListsScreen & ViewModel: add 'Liste beitreten' action button & dialog for entering invite code
- ListDetailScreen & ViewModel: add 'Liste teilen' action icon in top bar with System Share Sheet
- ADB reinstall & launch verified ✅
This commit is contained in:
parent
47b24dfcdf
commit
69591df12a
11 changed files with 457 additions and 56 deletions
|
|
@ -6,10 +6,6 @@ import retrofit2.http.*
|
|||
|
||||
/**
|
||||
* Retrofit API interface matching the mitbringsl backend.
|
||||
*
|
||||
* Authentication: every authenticated call must include
|
||||
* Authorization: Bearer <session-token>
|
||||
* This is injected by the AuthInterceptor in the OkHttp client.
|
||||
*/
|
||||
interface MitbringslApi {
|
||||
|
||||
|
|
@ -35,9 +31,15 @@ interface MitbringslApi {
|
|||
@POST("api/lists")
|
||||
suspend fun createList(@Body body: CreateListRequestDto): Response<ListDto>
|
||||
|
||||
@POST("api/lists/join")
|
||||
suspend fun joinList(@Body body: JoinListRequestDto): Response<ListDto>
|
||||
|
||||
@GET("api/lists/{id}")
|
||||
suspend fun getList(@Path("id") listId: String): Response<ListDetailDto>
|
||||
|
||||
@POST("api/lists/{id}/invite")
|
||||
suspend fun getInviteCode(@Path("id") listId: String): Response<InviteCodeResponseDto>
|
||||
|
||||
// --- Ops ----------------------------------------------------------------
|
||||
|
||||
@POST("api/lists/{id}/ops")
|
||||
|
|
|
|||
|
|
@ -48,10 +48,21 @@ data class UserDto(
|
|||
@Serializable
|
||||
data class CreateListRequestDto(val name: String)
|
||||
|
||||
@Serializable
|
||||
data class JoinListRequestDto(
|
||||
@SerialName("invite_code") val inviteCode: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InviteCodeResponseDto(
|
||||
@SerialName("invite_code") val inviteCode: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ListDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@SerialName("invite_code") val inviteCode: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String,
|
||||
@SerialName("hlc_ts") val hlcTs: Long,
|
||||
)
|
||||
|
|
@ -63,6 +74,7 @@ data class ListsResponseDto(val lists: List<ListDto>)
|
|||
data class ListDetailDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@SerialName("invite_code") val inviteCode: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String,
|
||||
@SerialName("hlc_ts") val hlcTs: Long,
|
||||
val items: List<ItemDto>,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.example.mitbringsl.ui.detail
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -12,13 +13,15 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -41,6 +44,10 @@ fun ListDetailScreen(
|
|||
val items by viewModel.items.collectAsStateWithLifecycle()
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
|
||||
val inviteCode by viewModel.inviteCode.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
var showShareDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val (openItems, checkedItems) = remember(items) {
|
||||
items.partition { !it.checked }
|
||||
|
|
@ -60,6 +67,16 @@ fun ListDetailScreen(
|
|||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!inviteCode.isNullOrBlank()) {
|
||||
IconButton(onClick = { showShareDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Liste teilen"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
|
|
@ -229,6 +246,65 @@ fun ListDetailScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showShareDialog && !inviteCode.isNullOrBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showShareDialog = false },
|
||||
title = { Text("Einkaufsliste teilen") },
|
||||
text = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Gib diesen Einladungs-Code an andere Personen weiter:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = inviteCode!!,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"Tritt meiner Einkaufsliste bei Mitbringsl bei! Einladungs-Code: ${inviteCode!!}"
|
||||
)
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, "Einladungs-Code teilen")
|
||||
context.startActivity(shareIntent)
|
||||
showShareDialog = false
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Share, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Code teilen")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showShareDialog = false }) {
|
||||
Text("Schließen")
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -30,9 +30,26 @@ class ListDetailViewModel @Inject constructor(
|
|||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private val _inviteCode = MutableStateFlow<String?>(null)
|
||||
val inviteCode: StateFlow<String?> = _inviteCode.asStateFlow()
|
||||
|
||||
fun setListId(id: String) {
|
||||
if (_listId.value != id) {
|
||||
_listId.value = id
|
||||
fetchInviteCode(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchInviteCode(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val resp = api.getInviteCode(id)
|
||||
if (resp.isSuccessful && resp.body() != null) {
|
||||
_inviteCode.value = resp.body()!!.inviteCode
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Offline or local list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import androidx.compose.material.icons.filled.Add
|
|||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.GroupAdd
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
import androidx.compose.material3.*
|
||||
|
|
@ -40,8 +40,10 @@ fun ListsScreen(
|
|||
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||
val isLoggedIn by viewModel.sessionManager.isLoggedIn.collectAsStateWithLifecycle()
|
||||
val userEmail = viewModel.sessionManager.getUserEmail()
|
||||
val joinError by viewModel.joinError.collectAsStateWithLifecycle()
|
||||
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var showJoinDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -53,7 +55,13 @@ fun ListsScreen(
|
|||
)
|
||||
},
|
||||
actions = {
|
||||
// Sync / Account button
|
||||
IconButton(onClick = { showJoinDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.GroupAdd,
|
||||
contentDescription = "Liste beitreten"
|
||||
)
|
||||
}
|
||||
|
||||
AssistChip(
|
||||
onClick = onOpenSync,
|
||||
label = {
|
||||
|
|
@ -155,11 +163,20 @@ fun ListsScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Erstelle direkt deine erste Liste — ohne Anmeldung nutzbar!",
|
||||
text = "Erstelle deine erste Liste oder tritt einer bestehenden Liste per Einladungs-Code bei.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(
|
||||
onClick = { showJoinDialog = true },
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.GroupAdd, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Liste per Code beitreten")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
|
|
@ -189,6 +206,21 @@ fun ListsScreen(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showJoinDialog) {
|
||||
JoinListDialog(
|
||||
errorMessage = joinError,
|
||||
onDismiss = {
|
||||
viewModel.clearJoinError()
|
||||
showJoinDialog = false
|
||||
},
|
||||
onConfirm = { code ->
|
||||
viewModel.joinList(code, onSuccess = {
|
||||
showJoinDialog = false
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -287,3 +319,59 @@ fun CreateListDialog(
|
|||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun JoinListDialog(
|
||||
errorMessage: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var code by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Liste beitreten") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Gib den 8-stelligen Einladungs-Code ein, um einer geteilten Liste beizutreten:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.uppercase() },
|
||||
label = { Text("Einladungs-Code") },
|
||||
placeholder = { Text("z.B. X7K9P2A1") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (errorMessage != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onConfirm(code) },
|
||||
enabled = code.isNotBlank(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text("Beitreten")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@ package com.example.mitbringsl.ui.lists
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.mitbringsl.data.auth.SessionManager
|
||||
import com.example.mitbringsl.data.local.dao.ListDao
|
||||
import com.example.mitbringsl.data.local.entity.ListEntity
|
||||
import com.example.mitbringsl.data.remote.api.MitbringslApi
|
||||
import com.example.mitbringsl.data.remote.dto.JoinListRequestDto
|
||||
import com.example.mitbringsl.data.repository.ShoppingRepository
|
||||
import com.example.mitbringsl.util.NetworkMonitor
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
|
@ -16,6 +21,8 @@ import javax.inject.Inject
|
|||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
private val shoppingRepository: ShoppingRepository,
|
||||
private val listDao: ListDao,
|
||||
private val api: MitbringslApi,
|
||||
val sessionManager: SessionManager,
|
||||
networkMonitor: NetworkMonitor,
|
||||
) : ViewModel() {
|
||||
|
|
@ -34,6 +41,9 @@ class ListsViewModel @Inject constructor(
|
|||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private val _joinError = MutableStateFlow<String?>(null)
|
||||
val joinError: StateFlow<String?> = _joinError.asStateFlow()
|
||||
|
||||
fun createList(name: String) {
|
||||
if (name.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
|
|
@ -41,6 +51,39 @@ class ListsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun joinList(inviteCode: String, onSuccess: () -> Unit) {
|
||||
if (inviteCode.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_joinError.value = null
|
||||
try {
|
||||
val resp = api.joinList(JoinListRequestDto(inviteCode.trim()))
|
||||
if (resp.isSuccessful && resp.body() != null) {
|
||||
val dto = resp.body()!!
|
||||
val ownerId = sessionManager.getUserId()
|
||||
listDao.upsert(
|
||||
ListEntity(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
ownerId = ownerId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
hlcTs = dto.hlcTs
|
||||
)
|
||||
)
|
||||
onSuccess()
|
||||
} else {
|
||||
_joinError.value = "Ungültiger Einladungs-Code oder Serverfehler."
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_joinError.value = e.localizedMessage ?: "Netzwerkfehler beim Beitreten."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearJoinError() {
|
||||
_joinError.value = null
|
||||
}
|
||||
|
||||
fun deleteList(listId: String) {
|
||||
viewModelScope.launch {
|
||||
shoppingRepository.deleteList(listId)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue