diff --git a/app/src/main/java/com/geozoner/app/ui/friends/FriendsScreen.kt b/app/src/main/java/com/geozoner/app/ui/friends/FriendsScreen.kt index 23a9fb8..18fdb88 100644 --- a/app/src/main/java/com/geozoner/app/ui/friends/FriendsScreen.kt +++ b/app/src/main/java/com/geozoner/app/ui/friends/FriendsScreen.kt @@ -1,52 +1,321 @@ 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.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.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.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.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.TextButton 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.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.Modifier +import androidx.compose.ui.draw.clip 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.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage 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) @Composable fun FriendsScreen( onNavigateBack: () -> Unit, + viewModel: FriendsViewModel = hiltViewModel(), ) { - Column(modifier = Modifier.fillMaxSize()) { - TopAppBar( - title = { Text(stringResource(R.string.friends_title)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } + 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 }, ) + } - Column( + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.friends_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + 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 .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .padding(innerPadding), ) { - Text( - text = "Friends list will appear here", - style = MaterialTheme.typography.bodyLarge, - ) + 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 = "No friends yet", + 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") + } + }, + ) +} diff --git a/app/src/main/java/com/geozoner/app/ui/friends/FriendsViewModel.kt b/app/src/main/java/com/geozoner/app/ui/friends/FriendsViewModel.kt new file mode 100644 index 0000000..045b02e --- /dev/null +++ b/app/src/main/java/com/geozoner/app/ui/friends/FriendsViewModel.kt @@ -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 = 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 = _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) + } +}