diff --git a/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt b/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt index 9758cf9..1bd5d5e 100644 --- a/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt +++ b/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt @@ -1,57 +1,57 @@ package com.example.dattingapp + +import android.app.Activity +import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import com.example.dattingapp.ui.theme.DattingAPPTheme import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import android.util.Log -import androidx.compose.foundation.clickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage import com.example.dattingapp.dataclass.Author +import com.example.dattingapp.dataclass.CommentResponse +import com.example.dattingapp.dataclass.Location import com.example.dattingapp.dataclass.ShowPostResponse +import com.example.dattingapp.homes.networkandapi.PostAllLikerResponse import com.example.dattingapp.homes.networkandapi.ShowPostUiState import com.example.dattingapp.homes.networkandapi.ShowPostViewModel -import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.CardDefaults -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import com.example.dattingapp.dataclass.CommentResponse -import com.example.dattingapp.dataclass.Location import com.example.dattingapp.homes.networkandapi.TokenManager +import com.example.dattingapp.homes.networkandapi.rememberPostInteraction +import com.example.dattingapp.ui.theme.DattingAPPTheme import com.example.dattingapp.ui.theme.TextPostTextColor val AccentPink = Color(0xFFD81B60) @@ -71,11 +71,12 @@ class ShowPostingActivity : ComponentActivity() { val showPostViewModel: ShowPostViewModel = viewModel() val uiState by showPostViewModel.showPostUiState.collectAsState() val comments by showPostViewModel.commentsUiState.collectAsState() + val postLikers by showPostViewModel.postLikers.collectAsState() - // Trigger API calls when the screen launches LaunchedEffect(postId) { showPostViewModel.fetchPostById(context, postId) showPostViewModel.fetchComments(context, postId) + showPostViewModel.fetchPostLikers(context, postId) } Scaffold( @@ -91,7 +92,6 @@ class ShowPostingActivity : ComponentActivity() { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } is ShowPostUiState.Success -> { - // Full screen scrollable container Column( modifier = Modifier .fillMaxSize() @@ -99,8 +99,11 @@ class ShowPostingActivity : ComponentActivity() { .padding(bottom = 24.dp) ) { PostCard( + context = context, + showPostViewModel = showPostViewModel, post = state.post, comments = comments, + postLikers = postLikers ) } } @@ -121,10 +124,26 @@ class ShowPostingActivity : ComponentActivity() { @Composable fun PostCard( + context: Context, + showPostViewModel: ShowPostViewModel, post: ShowPostResponse, comments: List, + postLikers: List, modifier: Modifier = Modifier ) { + // Live like/comment values shared with the feed screen + val live = rememberPostInteraction(post.id) + val likedByMe = live?.likedByMe ?: post.liked_by_me + val likeCount = live?.likeCount ?: post.like_count + val commentCount = live?.commentCount ?: comments.size + + // Owner check + menu state + val tokenManager = remember { TokenManager.getInstance(context) } + val currentUserId = remember { tokenManager.getUserId() } + val isPostOwner = post.author?.id != null && post.author?.id == currentUserId + var showPostMenu by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + Column( modifier = modifier .fillMaxWidth() @@ -140,7 +159,7 @@ fun PostCard( AsyncImage( model = post.author?.profile_image_url, contentDescription = "Post author image", - placeholder = painterResource(id = R.drawable.locationpermissionbg), // your existing placeholder + placeholder = painterResource(id = R.drawable.locationpermissionbg), error = painterResource(id = R.drawable.locationpermissionbg), modifier = Modifier .size(40.dp) @@ -149,26 +168,24 @@ fun PostCard( ) Spacer(modifier = Modifier.width(8.dp)) - Column { + + Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { val authorName = post.author?.full_name ?: "Anonymous" val feelingText = post.feeling ?: "" - val context = LocalContext.current - // ✅ Clickable author name Text( text = authorName, fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.clickable { val intent = Intent(context, FriendProfile::class.java).apply { - putExtra("USER_ID", post.author?.id) // ← send the user id + putExtra("USER_ID", post.author?.id) } context.startActivity(intent) } ) - // Rest of the line, unchanged if (feelingText.isNotEmpty()) { Text( text = buildAnnotatedString { @@ -192,12 +209,40 @@ fun PostCard( ) } } - } - - // --- 2. Post Text Content --- + // Three-dot menu: only for the post owner + if (isPostOwner) { + Box { + IconButton(onClick = { showPostMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Post options" + ) + } + DropdownMenu( + expanded = showPostMenu, + onDismissRequest = { showPostMenu = false } + ) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + showPostMenu = false + // TODO: edit post (empty for now) + } + ) + DropdownMenuItem( + text = { Text("Delete", color = Color.Red) }, + onClick = { + showPostMenu = false + showDeleteDialog = true + } + ) + } + } + } + } - // --- 3. Media Carousel (Images) --- + // --- 2. Media / text content --- if (post.media.isNotEmpty()) { if (!post.content.isNullOrEmpty()) { @@ -215,7 +260,6 @@ fun PostCard( .fillMaxWidth() .padding(start = 10.dp, end = 10.dp) .height(360.dp), - ) { AsyncImage( model = post.media.first().url, @@ -225,7 +269,7 @@ fun PostCard( ) } - }else{ + } else { Card( shape = RoundedCornerShape(16.dp), @@ -233,10 +277,7 @@ fun PostCard( .fillMaxWidth() .padding(start = 10.dp, end = 10.dp) .height(360.dp), - colors = CardDefaults.cardColors( - containerColor = Color.White // your background color - ) - + colors = CardDefaults.cardColors(containerColor = Color.White) ) { Box( modifier = Modifier.fillMaxSize(), @@ -256,21 +297,23 @@ fun PostCard( } } - // --- 4. Interaction Buttons --- + // --- 3. Interaction Buttons --- Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { /* Handle like action */ }) { + IconButton(onClick = { + showPostViewModel.toggleLikeLocallyAndServer(context, post.id) + }) { Icon( - imageVector = if (post.liked_by_me) Icons.Filled.Favorite else Icons.Default.FavoriteBorder, + imageVector = if (likedByMe) Icons.Filled.Favorite else Icons.Default.FavoriteBorder, contentDescription = "Like", - tint = if (post.liked_by_me) Color.Red else Color.Black + tint = if (likedByMe) Color.Red else Color.Black ) } - Text(text = "${post.like_count}") + Text(text = "$likeCount") Spacer(modifier = Modifier.width(16.dp)) @@ -280,19 +323,71 @@ fun PostCard( contentDescription = "Comments" ) } - Text(text = "${comments.size}") + Text(text = "$commentCount") + } + + // --- 4. Likers Summary Text --- + val currentUserName = tokenManager.currentUserName() ?: "You" + + val displayLikersText = when { + postLikers.isNotEmpty() -> { + val primaryLikerName = postLikers.first().fullName + val otherCount = postLikers.size - 1 + if (otherCount > 0) { + "$primaryLikerName and $otherCount other liked this" + } else { + "$primaryLikerName liked this" + } + } + likedByMe && likeCount > 0 -> { + if (likeCount > 1) { + "$currentUserName and ${likeCount - 1} other liked this" + } else { + "$currentUserName liked this" + } + } + else -> null + } + + if (displayLikersText != null) { + Text( + text = displayLikersText, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = Color.Gray, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp) + ) } // --- 5. Comment Section UI --- CommentSection( comments = comments, postId = post.id, - postUserName = post.author?.full_name // see note below + postUserName = post.author?.full_name, + postUserId = post.author?.id ) } -} - + // --- Delete confirmation dialog --- + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete post?") }, + text = { Text("This can't be undone.") }, + confirmButton = { + TextButton(onClick = { + showDeleteDialog = false + showPostViewModel.deletePost(context, post.id) { + (context as? Activity)?.finish() // close screen after delete + } + }) { Text("Delete", color = Color.Red) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } + } + ) + } +} @Composable fun CommentSection( @@ -311,8 +406,6 @@ fun CommentSection( val tokenManager = remember { TokenManager.getInstance(context) } val currentUserName = remember { tokenManager.currentUserName() } - val currentProfileImage = remember { tokenManager.currentProfileImage() } - // ✅ get the logged-in user's id once val currentUserId = remember { tokenManager.getUserId() } Column( @@ -380,8 +473,6 @@ fun CommentSection( comments.reversed().forEach { comment -> val isEditing = editingCommentId == comment.id - // ✅ Permission checks per comment - // adjust `comment.author?.id` to your actual model field (e.g. comment.user_id) val commentAuthorId = comment.author?.id val isCommentOwner = commentAuthorId != null && commentAuthorId == currentUserId val isPostOwner = postUserId != null && postUserId == currentUserId @@ -389,11 +480,6 @@ fun CommentSection( val canDelete = isCommentOwner || isPostOwner val hasAnyAction = canEdit || canDelete - Log.d( - "CommentPerm", - "current=$currentUserId | commentAuthor=$commentAuthorId | postOwner=$postUserId | canEdit=$canEdit canDelete=$canDelete" - ) - Row( modifier = Modifier .fillMaxWidth() @@ -401,9 +487,9 @@ fun CommentSection( verticalAlignment = Alignment.CenterVertically ) { AsyncImage( - model = currentProfileImage, - contentDescription = "Post author image", - placeholder = painterResource(id = R.drawable.locationpermissionbg), // your existing placeholder + model = comment.author?.profile_image_url, + contentDescription = "Comment author image", + placeholder = painterResource(id = R.drawable.locationpermissionbg), error = painterResource(id = R.drawable.locationpermissionbg), modifier = Modifier .size(32.dp) @@ -419,7 +505,6 @@ fun CommentSection( .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(CommentBubbleBg) - // ✅ only open the menu if the user can actually do something .clickable(enabled = !isEditing && hasAnyAction) { menuCommentId = comment.id } @@ -450,12 +535,7 @@ fun CommentSection( TextButton(onClick = { if (editText.isNotBlank()) { - // viewModel.editComment( - // context = context, - // postId = postId, - // commentId = comment.id, - // newContent = editText - // ) + // viewModel.editComment(...) not implemented yet editingCommentId = null editText = "" } @@ -473,7 +553,6 @@ fun CommentSection( expanded = menuCommentId == comment.id, onDismissRequest = { menuCommentId = null } ) { - // ✅ Edit: only the comment author if (canEdit) { DropdownMenuItem( text = { Text("Edit") }, @@ -484,7 +563,6 @@ fun CommentSection( } ) } - // ✅ Delete: comment author OR post owner if (canDelete) { DropdownMenuItem( text = { Text("Delete") }, @@ -506,11 +584,14 @@ fun CommentSection( Spacer(modifier = Modifier.height(8.dp)) } } + @Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable fun ShowPostScreenPreview() { DattingAPPTheme { - // Mock sample data mirroring your API schema + val context = LocalContext.current + val showPostViewModel: ShowPostViewModel = viewModel() + val mockPost = ShowPostResponse( id = 21, content = "Exploring the city and enjoying some coffee! ☕🏙️ #Android #JetpackCompose", @@ -527,7 +608,7 @@ fun ShowPostScreenPreview() { full_name = "Alex Rivera", city = "Shanghai", country = "China", - profile_image_url=null, + profile_image_url = null, compatibilityPercentage = 95 ), like_count = 12, @@ -546,7 +627,6 @@ fun ShowPostScreenPreview() { createdAt = "2026-10-13T06:06:15" ) - // Mock comments list val mockComments = listOf( CommentResponse( id = 1, @@ -557,7 +637,7 @@ fun ShowPostScreenPreview() { full_name = "Sarah", city = "Shanghai", country = "China", - profile_image_url=null, + profile_image_url = null, compatibilityPercentage = 100 ), createdAt = "2026-10-13T07:45:15" @@ -571,14 +651,19 @@ fun ShowPostScreenPreview() { full_name = "David", city = "Beijing", country = "China", - profile_image_url=null, + profile_image_url = null, compatibilityPercentage = 90 ), createdAt = "2026-10-13T08:04:48" ) ) - // Full screen container matching activity layout + val mockLikers = listOf( + PostAllLikerResponse(id = 1, fullName = "Parvej"), + PostAllLikerResponse(id = 2, fullName = "Sarah"), + PostAllLikerResponse(id = 3, fullName = "David") + ) + Column( modifier = Modifier .fillMaxSize() @@ -586,9 +671,12 @@ fun ShowPostScreenPreview() { .padding(vertical = 16.dp) ) { PostCard( + context = context, + showPostViewModel = showPostViewModel, post = mockPost, comments = mockComments, + postLikers = mockLikers ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt b/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt index c7a39e7..d094231 100644 --- a/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt +++ b/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt @@ -6,7 +6,6 @@ import androidx.media3.common.MediaItem as ExoMediaItem import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.indication import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -23,11 +22,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -39,6 +35,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView @@ -49,16 +46,15 @@ import com.example.dattingapp.dataclass.Author import com.example.dattingapp.dataclass.Location import com.example.dattingapp.dataclass.MediaItem import com.example.dattingapp.dataclass.ShowPostResponse -import com.example.dattingapp.homes.networkandapi.PostResponse +import com.example.dattingapp.homes.networkandapi.PostInteractionStore // NEW +import com.example.dattingapp.homes.networkandapi.rememberPostInteraction // NEW +import com.example.dattingapp.homes.networkandapi.ShowPostViewModel import com.example.dattingapp.homes.networkandapi.TokenManager -import com.example.dattingapp.homes.profile.UserResponse import com.example.dattingapp.ui.theme.DattingAPPTheme import com.example.dattingapp.ui.theme.OverlayShadow import com.example.dattingapp.ui.theme.TextPostTextColor import com.example.dattingapp.ui.theme.lightChipBg import com.example.dattingapp.ui.theme.primaryColor -import kotlinx.serialization.SerialName - // Data Models data class VibeCategory( @@ -67,7 +63,6 @@ data class VibeCategory( val isSelected: Boolean = false ) - data class UserMatch( val id: String, val name: String, @@ -77,11 +72,18 @@ data class UserMatch( @Composable fun VibeCheckHomeScreen( - feedResponse: List = emptyList(), // Change from MyProfileData? to MyProfileResponse? + feedResponse: List = emptyList(), isLoading: Boolean, errorMessage: String?, onRetry: () -> Unit = {}, ) { + // NEW: put feed data into the shared store (ignored for likes while a like is pending) + LaunchedEffect(feedResponse) { + feedResponse.forEach { + PostInteractionStore.setFromServer(it.id, it.liked_by_me, it.like_count, it.comment_count) + } + } + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -99,14 +101,6 @@ fun VibeCheckHomeScreen( SearchBarSection() } -// item { -// VibeFilterChipsSection( -// selectedColor = primaryColor, -// unselectedBg = lightChipBg -// ) -// } - - // One card per post from the API (replaces both hardcoded cards) items(feedResponse, key = { it.id }) { post -> FeaturedEventCard( post = post, @@ -114,7 +108,6 @@ fun VibeCheckHomeScreen( ) } - item { Spacer(modifier = Modifier.height(16.dp)) } @@ -149,7 +142,7 @@ private fun VibeCheckTopAppBar( AsyncImage( model = currentProfileImage, contentDescription = "Post author image", - placeholder = painterResource(id = com.example.dattingapp.R.drawable.locationpermissionbg), // your existing placeholder + placeholder = painterResource(id = R.drawable.locationpermissionbg), error = painterResource(id = R.drawable.locationpermissionbg), modifier = Modifier .size(40.dp) @@ -160,7 +153,7 @@ private fun VibeCheckTopAppBar( Spacer(modifier = Modifier.width(2.dp)) Text( - text = currentUserName?:"", + text = currentUserName ?: "", color = primaryColor, fontSize = 20.sp, fontWeight = FontWeight.Bold @@ -180,53 +173,51 @@ private fun VibeCheckTopAppBar( private fun SearchBarSection() { var searchText by remember { mutableStateOf("") } - OutlinedTextField( - value = searchText, - onValueChange = { searchText = it }, - placeholder = { - Text(text = "Search", color = Color.Gray, fontSize = 13.sp) - }, - leadingIcon = { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + placeholder = { + Text(text = "Search", color = Color.Gray, fontSize = 13.sp) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(18.dp) + ) + }, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { Icon( - imageVector = Icons.Default.Search, + imageVector = Icons.Outlined.Image, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(18.dp) ) - }, - trailingIcon = { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Image, - contentDescription = null, - tint = Color.Gray, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Icon( - imageVector = Icons.Outlined.LocationOn, - contentDescription = null, - tint = Color.Gray, - modifier = Modifier.size(18.dp) - ) - } - }, - shape = RoundedCornerShape(24.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedContainerColor = Color(0xFFF5F5F5), - focusedContainerColor = Color(0xFFF5F5F5), - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent - ), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - ) - + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Outlined.LocationOn, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(18.dp) + ) + } + }, + shape = RoundedCornerShape(24.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = Color(0xFFF5F5F5), + focusedContainerColor = Color(0xFFF5F5F5), + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent + ), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) } + @Composable private fun VibeFilterChipsSection( selectedColor: Color, @@ -271,14 +262,20 @@ private fun VibeFilterChipsSection( @Composable private fun FeaturedEventCard( post: ShowPostResponse, - badgeColor: Color + badgeColor: Color, + showPostViewModel: ShowPostViewModel = viewModel() ) { val context = LocalContext.current + // NEW: live like/comment values shared with the details screen + val live = rememberPostInteraction(post.id) + val likedByMe = live?.likedByMe ?: post.liked_by_me + val likeCount = live?.likeCount ?: post.like_count + val commentCount = live?.commentCount ?: post.comment_count + val hasMedia = post.media.isNotEmpty() val tags = listOfNotNull(post.feeling, post.location?.name) - // White over photos, dark over the white text-only card val contentColor = if (hasMedia) Color.White else TextPostTextColor val shadowStyle = if (hasMedia) TextStyle(shadow = OverlayShadow) else TextStyle.Default @@ -304,7 +301,7 @@ private fun FeaturedEventCard( if (hasMedia) { PostMediaPager(media = post.media, modifier = Modifier.fillMaxSize()) - // 2. Top scrim (keeps the badge readable) + // 2. Top scrim Box( modifier = Modifier .fillMaxWidth() @@ -317,7 +314,7 @@ private fun FeaturedEventCard( ) ) - // 3. Bottom scrim (keeps author, text and tags readable) + // 3. Bottom scrim Box( modifier = Modifier .fillMaxWidth() @@ -344,11 +341,13 @@ private fun FeaturedEventCard( ) } - // 4. Badge (it has its own colored background, so white text is fine) + // 4. Badge Surface( color = badgeColor, shape = RoundedCornerShape(12.dp), - modifier = Modifier.padding(12.dp).align(Alignment.TopStart) + modifier = Modifier + .padding(12.dp) + .align(Alignment.TopStart) ) { Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), @@ -374,7 +373,7 @@ private fun FeaturedEventCard( } } - // 5. Like / comment, on a dark pill when over a photo + // 5. Like / comment pillar Column( modifier = Modifier .align(Alignment.BottomEnd) @@ -382,20 +381,22 @@ private fun FeaturedEventCard( .clip(RoundedCornerShape(24.dp)) .padding(vertical = 4.dp, horizontal = 2.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) // space BETWEEN like and comment groups + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Like + count Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - imageVector = if (post.liked_by_me) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, - contentDescription = "Like", - tint = if (post.liked_by_me) Color.Red else contentColor, - modifier = Modifier - .size(28.dp) - .clickable { } - ) + IconButton(onClick = { + showPostViewModel.toggleLikeLocallyAndServer(context, post.id) + }) { + Icon( + imageVector = if (likedByMe) Icons.Default.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = "Like", + tint = if (likedByMe) Color.Red else contentColor, + modifier = Modifier.size(28.dp) + ) + } Text( - text = "${post.like_count}", + text = "$likeCount", color = contentColor, fontSize = 12.sp, style = shadowStyle @@ -408,12 +409,10 @@ private fun FeaturedEventCard( imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = "Comment", tint = contentColor, - modifier = Modifier - .size(28.dp) - .clickable { } + modifier = Modifier.size(28.dp) ) Text( - text = "${post.comment_count}", + text = "$commentCount", color = contentColor, fontSize = 12.sp, style = shadowStyle @@ -422,8 +421,11 @@ private fun FeaturedEventCard( } // 6. Bottom info - Column(modifier = Modifier.align(Alignment.BottomStart) - .padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 72.dp)) { + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 72.dp) + ) { Text( text = post.author?.full_name ?: "Anonymous", color = contentColor, @@ -468,6 +470,7 @@ private fun FeaturedEventCard( } } } + @Composable private fun PostMediaPager(media: List, modifier: Modifier = Modifier) { val pagerState = rememberPagerState(pageCount = { media.size }) @@ -490,12 +493,13 @@ private fun PostMediaPager(media: List, modifier: Modifier = Modifier } } - // "1/3" counter, only when there is more than one item if (media.size > 1) { Surface( color = Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(12.dp), - modifier = Modifier.align(Alignment.TopEnd).padding(12.dp) + modifier = Modifier + .align(Alignment.TopEnd) + .padding(12.dp) ) { Text( text = "${pagerState.currentPage + 1}/${media.size}", @@ -521,7 +525,6 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) { } } - // Pause when the user swipes to another page LaunchedEffect(isActive) { if (!isActive) player.pause() } diff --git a/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt b/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt index 91aaae1..e64574b 100644 --- a/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt +++ b/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -23,9 +24,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -40,9 +44,16 @@ import com.example.dattingapp.homes.networkandapi.SwipeViewModel import com.example.dattingapp.homes.networkandapi.TokenManager import com.example.dattingapp.ui.theme.BrandPink import com.example.dattingapp.ui.theme.BrandPurple +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.Period import java.time.format.DateTimeFormatter +import kotlin.math.abs + +// Direction strings sent to the backend. CHECK "superlike" matches what your API expects. +private const val DIR_LIKE = "like" +private const val DIR_PASS = "pass" +private const val DIR_SUPER_LIKE = "superlike" @Composable fun MeetDiscoveryScreen( @@ -138,16 +149,17 @@ fun MeetDiscoveryScreen( ProfileCardItem( profile = currentProfile, onSuperLike = { + handleSwipeAction(context, DIR_SUPER_LIKE, currentProfile, swipeViewModel) onSuperLikeClick(currentProfile) currentIndex++ }, onReject = { - handleSwipeAction(context,direction="pass", currentProfile, swipeViewModel) + handleSwipeAction(context, DIR_PASS, currentProfile, swipeViewModel) onRejectClick(currentProfile) currentIndex++ }, onLike = { - handleSwipeAction(context,direction="like", currentProfile, swipeViewModel) + handleSwipeAction(context, DIR_LIKE, currentProfile, swipeViewModel) onLikeClick(currentProfile) currentIndex++ } @@ -175,10 +187,15 @@ private fun ProfileCardItem( onLike: () -> Unit ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - // Reset drag offset whenever the profile changes + + // Drag offsets. Reset whenever the profile changes. val offsetX = remember(profile) { Animatable(0f) } + val offsetY = remember(profile) { Animatable(0f) } + + // Distance (in px) the card must be dragged before it counts as a swipe + val threshold = with(LocalDensity.current) { 120.dp.toPx() } + val flyDistance = with(LocalDensity.current) { 700.dp.toPx() } val photoUrl = remember(profile) { profile.media @@ -196,9 +213,69 @@ private fun ProfileCardItem( listOfNotNull(dist, loc.takeIf { it.isNotBlank() }).joinToString(" • ") } + // How strongly each stamp should show (0f..1f), based on the current drag + val x = offsetX.value + val y = offsetY.value + val verticalDominant = abs(y) > abs(x) + val likeAlpha = if (!verticalDominant) (x / threshold).coerceIn(0f, 1f) else 0f + val nopeAlpha = if (!verticalDominant) (-x / threshold).coerceIn(0f, 1f) else 0f + val superAlpha = if (verticalDominant) (-y / threshold).coerceIn(0f, 1f) else 0f + Box( modifier = Modifier .fillMaxSize() + // pointerInput comes BEFORE graphicsLayer so drag deltas are not + // measured in the moved/rotated coordinates (that would cause jitter) + .pointerInput(profile) { + detectDragGestures( + onDrag = { change, dragAmount -> + change.consume() + scope.launch { + offsetX.snapTo(offsetX.value + dragAmount.x) + offsetY.snapTo(offsetY.value + dragAmount.y) + } + }, + onDragEnd = { + scope.launch { + val dx = offsetX.value + val dy = offsetY.value + when { + // UP -> super like + dy < -threshold && abs(dy) > abs(dx) -> { + offsetY.animateTo(-flyDistance) + onSuperLike() + } + // RIGHT -> like + dx > threshold -> { + offsetX.animateTo(flyDistance) + onLike() + } + // LEFT -> reject + dx < -threshold -> { + offsetX.animateTo(-flyDistance) + onReject() + } + // Not far enough: snap back + else -> { + launch { offsetX.animateTo(0f) } + launch { offsetY.animateTo(0f) } + } + } + } + }, + onDragCancel = { + scope.launch { + launch { offsetX.animateTo(0f) } + launch { offsetY.animateTo(0f) } + } + } + ) + } + .graphicsLayer { + translationX = offsetX.value + translationY = offsetY.value + rotationZ = offsetX.value / 40f // slight tilt while dragging sideways + } .clip(RoundedCornerShape(20.dp)) ) { val token = remember(context) { @@ -209,7 +286,7 @@ private fun ProfileCardItem( if (!photoUrl.isNullOrBlank()) { AsyncImage( model = ImageRequest.Builder(context) - .data(photoUrl) // ← guaranteed non-null here + .data(photoUrl) .apply { if (!token.isNullOrBlank()) { val authHeader = @@ -230,7 +307,6 @@ private fun ProfileCardItem( } ) } else { - // No URL at all → show the default directly, Coil never runs Image( painter = defaultPainter, contentDescription = "Profile Image", @@ -251,6 +327,31 @@ private fun ProfileCardItem( ) ) + // SWIPE STAMPS (fade in as you drag) + SwipeStamp( + text = "LIKE", + color = Color(0xFF2ECC71), + alpha = likeAlpha, + modifier = Modifier + .align(Alignment.TopStart) + .padding(24.dp) + ) + SwipeStamp( + text = "NOPE", + color = Color(0xFFE74C3C), + alpha = nopeAlpha, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(24.dp) + ) + SwipeStamp( + text = "SUPER LIKE", + color = Color(0xFF3498DB), + alpha = superAlpha, + modifier = Modifier + .align(Alignment.Center) + ) + // ACTION BUTTONS (Right Side) Column( modifier = Modifier @@ -323,12 +424,10 @@ private fun ProfileCardItem( .fillMaxWidth() .clickable { val intent = Intent(context, FriendProfile::class.java).apply { - putExtra("USER_ID",profile.id ) - + putExtra("USER_ID", profile.id) } context.startActivity(intent) }, - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -403,7 +502,32 @@ private fun ProfileCardItem( } } -//Helper to calculate age from birth date string (e.g., "1999-05-15") +// The LIKE / NOPE / SUPER LIKE label that fades in while dragging +@Composable +private fun SwipeStamp( + text: String, + color: Color, + alpha: Float, + modifier: Modifier = Modifier +) { + if (alpha <= 0f) return + Surface( + modifier = modifier.graphicsLayer { this.alpha = alpha }, + color = Color.Transparent, + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(3.dp, color) + ) { + Text( + text = text, + color = color, + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } +} + +// Helper to calculate age from birth date string (e.g., "1999-05-15") private fun calculateAge(birthDateStr: String?): Int? { if (birthDateStr.isNullOrBlank()) return null return try { @@ -415,20 +539,17 @@ private fun calculateAge(birthDateStr: String?): Int? { } } -// Helper method for reject swipe network call +// Sends the swipe (like / pass / superlike) to the server private fun handleSwipeAction( context: Context, - direction:String, + direction: String, profile: MeetUserProfile, swipeViewModel: SwipeViewModel ) { - val targetUserId = profile.id - val swipeRequest = SwipeRequest(targetUserId, direction) + val swipeRequest = SwipeRequest(profile.id, direction) swipeViewModel.sendSwipeResponse(context, swipeRequest) } - - // ========================================== // PREVIEWS & MOCK DATA // ========================================== @@ -443,7 +564,7 @@ private val mockProfiles = listOf( distanceKm = 4.2, bio = "Coffee enthusiast, UI designer, and full-time dog lover 🐶✨ Looking for someone to explore new coffee shops with!", compatibilityPercentage = 92, - media = null, // Triggers fallback default Person icon + media = null, gender = "Male", videoUrl = null ), @@ -477,4 +598,4 @@ fun MeetDiscoveryScreenPreview() { onLikeClick = {} ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/dattingapp/homes/MyProfile.kt b/app/src/main/java/com/example/dattingapp/homes/MyProfile.kt index 8859457..e4252b9 100644 --- a/app/src/main/java/com/example/dattingapp/homes/MyProfile.kt +++ b/app/src/main/java/com/example/dattingapp/homes/MyProfile.kt @@ -11,12 +11,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.TextSnippet -import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.* import androidx.compose.runtime.* @@ -35,15 +33,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.example.dattingapp.LikeANdMatchActivity -import com.example.dattingapp.MasonryPostItem import com.example.dattingapp.R import com.example.dattingapp.ShowPostingActivity -import com.example.dattingapp.StaggeredFriendPostsGrid +import com.example.dattingapp.homes.networkandapi.PostInteractionStore // NEW import com.example.dattingapp.homes.profile.Author import com.example.dattingapp.homes.profile.Media -import com.example.dattingapp.homes.profile.UserResponse import com.example.dattingapp.homes.profile.Post import com.example.dattingapp.homes.profile.ProfileData +import com.example.dattingapp.homes.profile.UserResponse import com.example.dattingapp.ui.theme.BrandMagenta import com.example.dattingapp.ui.theme.BrandPurple import com.example.dattingapp.ui.theme.DattingAPPTheme @@ -84,6 +81,10 @@ fun MyProfileScreen( val profile = profileResponse?.profile val age = calculateAge(profile?.birth_date) + // NEW: posts deleted anywhere in the app + val deletedIds by PostInteractionStore.deletedIds.collectAsState() + val visiblePosts = (profileResponse?.posts ?: emptyList()).filter { it.id !in deletedIds } + Column( modifier = Modifier .fillMaxSize() @@ -171,7 +172,6 @@ fun MyProfileScreen( .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 8.dp) ) { - // NAME, AGE & EDIT BUTTON Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -188,12 +188,10 @@ fun MyProfileScreen( color = textDark ) } - } Spacer(modifier = Modifier.height(4.dp)) - // LOCATION & STATUS val locationText = listOfNotNull(profile?.city, profile?.country).joinToString(", ") if (locationText.isNotBlank()) { Text( @@ -204,7 +202,6 @@ fun MyProfileScreen( Spacer(modifier = Modifier.height(6.dp)) } - // BIO CONTAINER if (!profile?.bio.isNullOrBlank()) { Card( modifier = Modifier.fillMaxWidth(), @@ -223,7 +220,6 @@ fun MyProfileScreen( Spacer(modifier = Modifier.height(10.dp)) } - // INTERESTS CHIPS if (!profile?.interests.isNullOrEmpty()) { FlowRow( modifier = Modifier.fillMaxWidth(), @@ -348,7 +344,7 @@ fun MyProfileScreen( Spacer(modifier = Modifier.height(20.dp)) // ==================================================== - // ALREADY POSTED / DYNAMIC POSTS SECTION + // POSTED SECTION // ==================================================== Text( text = "Posted", @@ -359,14 +355,12 @@ fun MyProfileScreen( ) Spacer(modifier = Modifier.height(10.dp)) - ProfilePostsGrid(posts = profileResponse?.posts ?: emptyList()) + ProfilePostsGrid(posts = visiblePosts) // CHANGED: deleted posts are filtered out Spacer(modifier = Modifier.height(30.dp)) } } - - // ============================================================ // STAT CARD // ============================================================ @@ -406,11 +400,9 @@ private fun StatCard( } } - // ============================================================ -// STAGGERED PROFILE POSTS GRID (Matches your custom Masonry layout) +// STAGGERED PROFILE POSTS GRID // ============================================================ - @Composable private fun ProfilePostsGrid(posts: List) { if (posts.isEmpty()) { @@ -425,17 +417,12 @@ private fun ProfilePostsGrid(posts: List) { return } - // Distribute posts into two columns val columns = remember(posts) { val col1 = mutableListOf() val col2 = mutableListOf() posts.forEachIndexed { index, post -> - if (index % 2 == 0) { - col1.add(post) - } else { - col2.add(post) - } + if (index % 2 == 0) col1.add(post) else col2.add(post) } Pair(col1, col2) } @@ -446,7 +433,6 @@ private fun ProfilePostsGrid(posts: List) { .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - // Column 1 Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp) @@ -456,7 +442,6 @@ private fun ProfilePostsGrid(posts: List) { MasonryProfilePostItem(post = post, aspectRatio = aspectRatio) } } - // Column 2 Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp) @@ -488,10 +473,7 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) { context.startActivity(intent) } ) { - Box( - modifier = Modifier - .fillMaxSize() - ) { + Box(modifier = Modifier.fillMaxSize()) { if (isImage && !primaryMedia.isNullOrEmpty()) { AsyncImage( model = primaryMedia, @@ -500,7 +482,6 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) { modifier = Modifier.fillMaxSize() ) - // Dark Gradient Scrim for text readability over images Box( modifier = Modifier .fillMaxSize() @@ -512,7 +493,6 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) { ) ) - // Image metadata overlay Column( modifier = Modifier .align(Alignment.BottomStart) @@ -538,7 +518,6 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) { } } } else { - // Text / Status Card Design Column( modifier = Modifier .fillMaxSize() @@ -600,7 +579,6 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) { ) } - // Footer Likes Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) @@ -624,14 +602,12 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) { } // ============================================================ -// PREVIEWS +// PREVIEW // ============================================================ - @Preview(showBackground = true, showSystemUi = true) @Composable fun MyProfileScreenSuccessPreview() { DattingAPPTheme { - // Mock UserResponse matching your backend model structure val mockUserResponse = UserResponse( id = 1, email_or_phone = "elena@example.com", @@ -639,7 +615,7 @@ fun MyProfileScreenSuccessPreview() { profile = ProfileData( full_name = "Elena", gender = "Female", - birth_date = "2002-04-12", // 24 years old + birth_date = "2002-04-12", city = "New York", county = null, country = "USA", @@ -663,14 +639,7 @@ fun MyProfileScreenSuccessPreview() { comment_count = 3, liked_by_me = false, media = listOf( - Media( - 1, - "image", - "image/jpeg", - 1024, - 1, - "https://picsum.photos/400/400?random=1" - ), + Media(1, "image", "image/jpeg", 1024, 1, "https://picsum.photos/400/400?random=1"), Media(2, "image", "image/jpeg", 1024, 2, "https://picsum.photos/400/400?random=2"), Media(3, "image", "image/jpeg", 1024, 3, "https://picsum.photos/400/400?random=3") ), diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt index ada4044..567d9c0 100644 --- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt +++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt @@ -109,4 +109,21 @@ interface HomeApiService { @Query("offset") offset: Int = 0, ): Response> + + @POST("api/v1/explore/posts/{post_id}/like") + suspend fun toggleLikePost( + @Path("post_id") postId: Int + ): Response + + + @GET("api/v1/explore/posts/{post_id}/like") + suspend fun getPostLikers( + @Path("post_id") postId: Int + ): Response> + + @DELETE("api/v1/explore/posts/{post_id}") + suspend fun deletePost( + @Path("post_id") postId: Int + ): Response + } \ No newline at end of file diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt index d972131..afa0060 100644 --- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt +++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt @@ -1,6 +1,15 @@ package com.example.dattingapp.homes.networkandapi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update private const val MEDIA_BASE_URL = "https://melove.youthome.com.cn" @@ -54,26 +63,30 @@ data class MeetUserProfile( } -/** - * Standard API wrapper format for MyProfile - */ -//data class MyProfileResponse( -// @SerializedName("id") val id: Int?, -// @SerializedName("email_or_phone") val emailOrPhone: String?, -// @SerializedName("status") val status: String?, -// @SerializedName("profile") val profile: UserProfileDetails?, // Nested profile details -// @SerializedName("created_at") val createdAt: String? -//) -// -//data class UserProfileDetails( -// @SerializedName("full_name") val fullName: String?, -// @SerializedName("gender") val gender: String?, -// @SerializedName("city") val city: String?, -// @SerializedName("country") val country: String?, -// @SerializedName("bio") val bio: String?, -// @SerializedName("profile_image_url") val profileImageUrl: String?, // Resolved field -// @SerializedName("video_url") val videoUrl: String? -//) + + +data class LikedPost( + @SerializedName("id") + val id: Int, + @SerializedName("full_name") + val fullName: String +) + +data class LikePostResponse( + @SerializedName("message") + val message: String, + @SerializedName("like_count") + val likeCount: Int, + @SerializedName("liked_by_me") + val likedByMe: Boolean, + @SerializedName("liked_by_users") + val likedByUsers: List? +) + +data class PostAllLikerResponse( + val id: Int, + @SerializedName("full_name") val fullName: String +) data class MediaItem( @SerializedName("id") @@ -206,7 +219,9 @@ data class CreatePostResponse( val created_at: String ) - +data class DeletePostResponse( + val message: String +) data class PostMediaResponse( val id: Int, @@ -216,3 +231,88 @@ data class PostMediaResponse( val position: Int, val url: String ) + +data class PostInteraction( + val likedByMe: Boolean, + val likeCount: Int, + val commentCount: Int +) + +object PostInteractionStore { + private val _state = MutableStateFlow>(emptyMap()) + val state: StateFlow> = _state.asStateFlow() + + // NEW: ids of posts deleted in this session; every screen hides these + private val _deletedIds = MutableStateFlow>(emptySet()) + val deletedIds: StateFlow> = _deletedIds.asStateFlow() + + private val pendingLikes = java.util.Collections.synchronizedSet(mutableSetOf()) + + fun markLikePending(postId: Int) { pendingLikes.add(postId) } + fun clearLikePending(postId: Int) { pendingLikes.remove(postId) } + + /** Call whenever data arrives from the server (feed load, fetchPostById). */ + fun setFromServer(postId: Int, liked: Boolean, likeCount: Int, commentCount: Int) { + _state.update { map -> + val cur = map[postId] + if (postId in pendingLikes && cur != null) { + map + (postId to cur.copy(commentCount = commentCount)) + } else { + map + (postId to PostInteraction(liked, likeCount, commentCount)) + } + } + } + + fun toggleLike(postId: Int) { + _state.update { map -> + val cur = map[postId] ?: return@update map + val liked = !cur.likedByMe + map + (postId to cur.copy( + likedByMe = liked, + likeCount = (cur.likeCount + if (liked) 1 else -1).coerceAtLeast(0) + )) + } + } + + fun applyLikeResult(postId: Int, liked: Boolean, likeCount: Int) { + _state.update { map -> + val cur = map[postId] ?: return@update map + map + (postId to cur.copy(likedByMe = liked, likeCount = likeCount)) + } + } + + fun changeCommentCount(postId: Int, delta: Int) { + _state.update { map -> + val cur = map[postId] ?: return@update map + map + (postId to cur.copy(commentCount = (cur.commentCount + delta).coerceAtLeast(0))) + } + } + + fun setCommentCount(postId: Int, count: Int) { + _state.update { map -> + val cur = map[postId] ?: return@update map + map + (postId to cur.copy(commentCount = count)) + } + } + + /** Call after a post is deleted on the server. */ + fun remove(postId: Int) { + _state.update { it - postId } + _deletedIds.update { it + postId } // NEW: tells every screen to hide this post + } + + /** Call on logout. */ + fun clear() { + _state.value = emptyMap() + _deletedIds.value = emptySet() // NEW + pendingLikes.clear() + } +} + +@Composable +fun rememberPostInteraction(postId: Int): PostInteraction? { + val flow = remember(postId) { + PostInteractionStore.state.map { it[postId] }.distinctUntilChanged() + } + return flow.collectAsState(initial = PostInteractionStore.state.value[postId]).value +} \ No newline at end of file diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt index 0c49f58..e78a31e 100644 --- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt +++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt @@ -595,7 +595,6 @@ class LikedViewModel : ViewModel() { } } -// --- UI State --- sealed interface ShowPostUiState { object Idle : ShowPostUiState object Loading : ShowPostUiState @@ -609,17 +608,20 @@ class ShowPostViewModel : ViewModel() { private val _showPostUiState = MutableStateFlow(ShowPostUiState.Idle) val showPostUiState: StateFlow = _showPostUiState.asStateFlow() + private val _commentsUiState = MutableStateFlow>(emptyList()) + val commentsUiState: StateFlow> = _commentsUiState.asStateFlow() + + private val _postLikers = MutableStateFlow>(emptyList()) + val postLikers: StateFlow> = _postLikers.asStateFlow() + fun fetchPostById(context: Context, postId: Int) { - if (_showPostUiState.value is ShowPostUiState.Loading) return + if (_showPostUiState.value is ShowPostUiState.Loading) return val appContext = context.applicationContext viewModelScope.launch { + _showPostUiState.value = ShowPostUiState.Loading - _showPostUiState.value = ShowPostUiState.Loading - - - // Token check if (TokenManager.getInstance(appContext).getAccessToken().isNullOrBlank()) { Log.e("PostViewModel", "No access token found in preferences") _showPostUiState.value = ShowPostUiState.Error("No access token found. Please log in again.") @@ -627,17 +629,21 @@ class ShowPostViewModel : ViewModel() { } try { - // Call API via Network Module val response = HomeNetworkModule .getApiService(appContext) .getPostById(postId) if (response.isSuccessful && response.body() != null) { - _showPostUiState.value = ShowPostUiState.Success(response.body()!!) + val post = response.body()!! + // NEW: put server data in the shared store (ignored for likes while a like is pending) + PostInteractionStore.setFromServer( + post.id, post.liked_by_me, post.like_count, post.comment_count + ) + _showPostUiState.value = ShowPostUiState.Success(post) } else { val errorBody = response.errorBody()?.string() Log.e("PostError", "Code: ${response.code()}, Error: $errorBody") - _showPostUiState.value = ShowPostUiState.Error("Failed to fetch post data from server. $errorBody") + _showPostUiState.value = ShowPostUiState.Error("Failed to fetch post data from server. $errorBody") } } catch (e: HttpException) { @@ -654,6 +660,7 @@ class ShowPostViewModel : ViewModel() { } } } + fun addCommentLocallyAndServer(context: Context, postId: Int, content: String, currentUserName: String?) { val appContext = context.applicationContext @@ -673,10 +680,9 @@ class ShowPostViewModel : ViewModel() { ) val currentList = _commentsUiState.value.toMutableList() - - // Add the new comment at index 0 so it's instantly at the top currentList.add(0, optimisticComment) _commentsUiState.value = currentList + PostInteractionStore.changeCommentCount(postId, +1) // NEW viewModelScope.launch { try { @@ -689,17 +695,15 @@ class ShowPostViewModel : ViewModel() { fetchComments(appContext, postId) } else { Log.e("CommentError", "Failed to sync comment with server") + PostInteractionStore.changeCommentCount(postId, -1) // NEW } } catch (e: Exception) { Log.e("CommentViewModel", "Exception while posting comment", e) + PostInteractionStore.changeCommentCount(postId, -1) // NEW } } } - - private val _commentsUiState = MutableStateFlow>(emptyList()) - val commentsUiState: StateFlow> = _commentsUiState.asStateFlow() - fun fetchComments(context: Context, postId: Int) { val appContext = context.applicationContext @@ -710,7 +714,9 @@ class ShowPostViewModel : ViewModel() { .getPostComments(postId) if (response.isSuccessful && response.body() != null) { - _commentsUiState.value = response.body()!! + val list = response.body()!! + _commentsUiState.value = list + PostInteractionStore.setCommentCount(postId, list.size) // NEW } else { Log.e("CommentError", "Failed to fetch comments: ${response.errorBody()?.string()}") } @@ -720,11 +726,75 @@ class ShowPostViewModel : ViewModel() { } } + // NEW: no longer depends on ShowPostUiState.Success, so it works from the feed too + fun toggleLikeLocallyAndServer(context: Context, postId: Int) { + val appContext = context.applicationContext + + // 1. Optimistic update: feed AND details screens change instantly + PostInteractionStore.markLikePending(postId) + PostInteractionStore.toggleLike(postId) + + // 2. Server request + viewModelScope.launch { + try { + val response = HomeNetworkModule + .getApiService(appContext) + .toggleLikePost(postId) + + if (response.isSuccessful && response.body() != null) { + val serverData = response.body()!! + PostInteractionStore.applyLikeResult( + postId, serverData.likedByMe, serverData.likeCount + ) + + // keep this screen's own post state in sync too (if loaded) + val s = _showPostUiState.value + if (s is ShowPostUiState.Success && s.post.id == postId) { + _showPostUiState.value = ShowPostUiState.Success( + s.post.copy( + liked_by_me = serverData.likedByMe, + like_count = serverData.likeCount + ) + ) + } + } else { + Log.e("LikeError", "Failed to sync like with server, rolling back") + PostInteractionStore.toggleLike(postId) + } + } catch (e: Exception) { + Log.e("LikeViewModel", "Exception while toggling like, rolling back", e) + PostInteractionStore.toggleLike(postId) + } finally { + PostInteractionStore.clearLikePending(postId) + } + } + } + + fun fetchPostLikers(context: Context, postId: Int) { + val appContext = context.applicationContext + viewModelScope.launch { + try { + val response = HomeNetworkModule + .getApiService(appContext) + .getPostLikers(postId) + + if (response.isSuccessful && response.body() != null) { + _postLikers.value = response.body()!! + } else { + Log.e("LikersError", "Failed to fetch likers: ${response.errorBody()?.string()}") + } + } catch (e: Exception) { + Log.e("LikersViewModel", "Exception while fetching likers", e) + } + } + } + fun deleteComment(context: Context, postId: Int, commentId: Int) { val appContext = context.applicationContext val previousList = _commentsUiState.value _commentsUiState.value = previousList.filter { it.id != commentId } + PostInteractionStore.changeCommentCount(postId, -1) // NEW viewModelScope.launch { try { @@ -736,19 +806,16 @@ class ShowPostViewModel : ViewModel() { Log.d("CommentError", "Delete success: code=${response.code()} commentId=$commentId") fetchComments(appContext, postId) } else { - // ---- read the RAW error body first, so nothing is lost ---- val rawError = try { response.errorBody()?.string() ?: "(empty error body)" } catch (e: Exception) { "(failed to read error body: ${e.message})" } - // ---- try to extract "detail" from the JSON ---- val detail = try { JSONObject(rawError).optString("detail") } catch (e: Exception) { "" } - // ---- human-readable reason per status code ---- val reason = when (response.code()) { 401 -> "Not authorized (token missing/expired — log in again)" 403 -> "Not allowed: $detail" @@ -758,18 +825,13 @@ class ShowPostViewModel : ViewModel() { else -> detail.ifBlank { rawError } } - // ---- FULL diagnosis in Logcat (filter: CommentError) ---- Log.e( "CommentError", - "Delete failed\n" + - " code = ${response.code()}\n" + - " message = ${response.message()}\n" + - " url = ${response.raw().request.url}\n" + - " raw body = $rawError\n" + - " parsed = $reason" + "Delete failed\n code = ${response.code()}\n message = ${response.message()}\n url = ${response.raw().request.url}\n raw body = $rawError\n parsed = $reason" ) _commentsUiState.value = previousList + PostInteractionStore.changeCommentCount(postId, +1) // NEW (rollback) Toast.makeText( appContext, "Delete failed (${response.code()}): $reason", @@ -777,13 +839,13 @@ class ShowPostViewModel : ViewModel() { ).show() } } catch (e: Exception) { - // ---- network-level failure: no internet, timeout, DNS, SSL... ---- Log.e( "CommentError", "Exception while deleting commentId=$commentId: ${e.javaClass.simpleName}: ${e.message}", e ) _commentsUiState.value = previousList + PostInteractionStore.changeCommentCount(postId, +1) // NEW (rollback) Toast.makeText( appContext, "Delete failed: ${e.javaClass.simpleName} — ${e.message ?: "no details"}", @@ -793,8 +855,29 @@ class ShowPostViewModel : ViewModel() { } } + fun deletePost(context: Context, postId: Int, onDeleted: () -> Unit) { + val appContext = context.applicationContext + viewModelScope.launch { + try { + val response = HomeNetworkModule + .getApiService(appContext) + .deletePost(postId) + if (response.isSuccessful) { + PostInteractionStore.remove(postId) // clean up shared store + onDeleted() // e.g. finish() the activity + } else { + val err = response.errorBody()?.string() + Log.e("DeletePost", "Failed ${response.code()}: $err") + Toast.makeText(appContext, "Delete failed (${response.code()})", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Log.e("DeletePost", "Exception while deleting post", e) + Toast.makeText(appContext, "Delete failed: ${e.javaClass.simpleName}", Toast.LENGTH_LONG).show() + } + } + } } sealed interface FeedUiState { @@ -809,6 +892,8 @@ class ExploreFeedViewModel : ViewModel() { private val _uiState = MutableStateFlow(FeedUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + + private val pageSize = 20 private var isLoadingMore = false