Implement Friends feature with ViewModel and UI enhancements
- Added FriendsViewModel to manage friends list, including loading, adding, and removing friends. - Enhanced FriendsScreen UI with a dialog for adding friends, snackbar notifications for actions, and improved empty state handling. - Integrated PullToRefresh functionality for refreshing the friends list. - Updated dependencies and imports for Material3 components.
This commit is contained in:
@@ -1,33 +1,110 @@
|
|||||||
package com.geozoner.app.ui.friends
|
package com.geozoner.app.ui.friends
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
import com.geozoner.app.R
|
import com.geozoner.app.R
|
||||||
|
import com.geozoner.app.data.remote.dto.UserResponse
|
||||||
|
|
||||||
/**
|
|
||||||
* Friends screen — list friends, add by username, remove.
|
|
||||||
* TODO: Fetch from GET /friends, POST /friends, DELETE /friends/{id}.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FriendsScreen(
|
fun FriendsScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: FriendsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
var showAddDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Show snackbar on add result or error
|
||||||
|
LaunchedEffect(state.addFriendResult) {
|
||||||
|
state.addFriendResult?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.clearAddFriendState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.addFriendError) {
|
||||||
|
state.addFriendError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.clearAddFriendState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.error) {
|
||||||
|
state.error?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Friend dialog
|
||||||
|
if (showAddDialog) {
|
||||||
|
AddFriendDialog(
|
||||||
|
isAdding = state.isAdding,
|
||||||
|
onDismiss = { showAddDialog = false },
|
||||||
|
onAdd = { username ->
|
||||||
|
viewModel.addFriend(username)
|
||||||
|
showAddDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.friends_title)) },
|
title = { Text(stringResource(R.string.friends_title)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -36,17 +113,209 @@ fun FriendsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
},
|
||||||
Column(
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { showAddDialog = true }) {
|
||||||
|
Icon(Icons.Default.PersonAdd, contentDescription = stringResource(R.string.add_friend))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) { innerPadding ->
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = state.isLoading,
|
||||||
|
onRefresh = { viewModel.loadFriends() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp),
|
.padding(innerPadding),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
|
if (state.friends.isEmpty() && !state.isLoading) {
|
||||||
|
// Empty state
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Friends list will appear here",
|
text = "No friends yet",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Tap + to add friends by username",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = state.friends,
|
||||||
|
key = { it.id },
|
||||||
|
) { friend ->
|
||||||
|
FriendItem(
|
||||||
|
friend = friend,
|
||||||
|
onRemove = { viewModel.removeFriend(friend.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun FriendItem(
|
||||||
|
friend: UserResponse,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
|
confirmValueChange = { value ->
|
||||||
|
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||||
|
onRemove()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
backgroundContent = {
|
||||||
|
val color by animateColorAsState(
|
||||||
|
targetValue = if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
label = "swipe-bg",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color)
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Remove friend",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enableDismissFromStartToEnd = false,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Avatar
|
||||||
|
if (friend.avatarUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = friend.avatarUrl,
|
||||||
|
contentDescription = "${friend.username} avatar",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = friend.username.first().uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
// Info
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = friend.username,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = friend.email,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddFriendDialog(
|
||||||
|
isAdding: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAdd: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var username by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.add_friend)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text(stringResource(R.string.add_friend_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isAdding,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { onAdd(username.trim()) },
|
||||||
|
enabled = username.isNotBlank() && !isAdding,
|
||||||
|
) {
|
||||||
|
if (isAdding) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.add_friend))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.geozoner.app.ui.friends
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.geozoner.app.data.remote.api.FriendApi
|
||||||
|
import com.geozoner.app.data.remote.dto.UserResponse
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class FriendsUiState(
|
||||||
|
val friends: List<UserResponse> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val addFriendResult: String? = null,
|
||||||
|
val addFriendError: String? = null,
|
||||||
|
val isAdding: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FriendsViewModel @Inject constructor(
|
||||||
|
private val friendApi: FriendApi,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(FriendsUiState())
|
||||||
|
val uiState: StateFlow<FriendsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadFriends()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFriends() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
try {
|
||||||
|
val friends = friendApi.getFriends()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
friends = friends,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Failed to load friends",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addFriend(username: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = _uiState.value.copy(isAdding = true, addFriendError = null, addFriendResult = null)
|
||||||
|
try {
|
||||||
|
val result = friendApi.addFriend(username)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isAdding = false,
|
||||||
|
addFriendResult = result.detail,
|
||||||
|
)
|
||||||
|
loadFriends() // Refresh list
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isAdding = false,
|
||||||
|
addFriendError = e.message ?: "Failed to add friend",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeFriend(friendId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
friendApi.removeFriend(friendId)
|
||||||
|
// Remove from local state immediately
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
friends = _uiState.value.friends.filter { it.id != friendId },
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = e.message ?: "Failed to remove friend",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAddFriendState() {
|
||||||
|
_uiState.value = _uiState.value.copy(addFriendResult = null, addFriendError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user