Browse Source

feed update

master
osman160128 1 week ago
parent
commit
7873619494
  1. 248
      app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt
  2. 179
      app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt
  3. 157
      app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt
  4. 59
      app/src/main/java/com/example/dattingapp/homes/MyProfile.kt
  5. 17
      app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt
  6. 142
      app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt
  7. 139
      app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt

248
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<CommentResponse>,
postLikers: List<PostAllLikerResponse>,
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
)
}
}
}
}

179
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<ShowPostResponse> = emptyList(), // Change from MyProfileData? to MyProfileResponse?
feedResponse: List<ShowPostResponse> = 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<MediaItem>, modifier: Modifier = Modifier) {
val pagerState = rememberPagerState(pageCount = { media.size })
@ -490,12 +493,13 @@ private fun PostMediaPager(media: List<MediaItem>, 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()
}

157
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 = {}
)
}
}
}

59
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<Post>) {
if (posts.isEmpty()) {
@ -425,17 +417,12 @@ private fun ProfilePostsGrid(posts: List<Post>) {
return
}
// Distribute posts into two columns
val columns = remember(posts) {
val col1 = mutableListOf<Post>()
val col2 = mutableListOf<Post>()
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<Post>) {
.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<Post>) {
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")
),

17
app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt

@ -109,4 +109,21 @@ interface HomeApiService {
@Query("offset") offset: Int = 0,
): Response<List<ShowPostResponse>>
@POST("api/v1/explore/posts/{post_id}/like")
suspend fun toggleLikePost(
@Path("post_id") postId: Int
): Response<LikePostResponse>
@GET("api/v1/explore/posts/{post_id}/like")
suspend fun getPostLikers(
@Path("post_id") postId: Int
): Response<List<PostAllLikerResponse>>
@DELETE("api/v1/explore/posts/{post_id}")
suspend fun deletePost(
@Path("post_id") postId: Int
): Response<DeletePostResponse>
}

142
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<LikedPost>?
)
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<Map<Int, PostInteraction>>(emptyMap())
val state: StateFlow<Map<Int, PostInteraction>> = _state.asStateFlow()
// NEW: ids of posts deleted in this session; every screen hides these
private val _deletedIds = MutableStateFlow<Set<Int>>(emptySet())
val deletedIds: StateFlow<Set<Int>> = _deletedIds.asStateFlow()
private val pendingLikes = java.util.Collections.synchronizedSet(mutableSetOf<Int>())
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
}

139
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>(ShowPostUiState.Idle)
val showPostUiState: StateFlow<ShowPostUiState> = _showPostUiState.asStateFlow()
private val _commentsUiState = MutableStateFlow<List<CommentResponse>>(emptyList())
val commentsUiState: StateFlow<List<CommentResponse>> = _commentsUiState.asStateFlow()
private val _postLikers = MutableStateFlow<List<PostAllLikerResponse>>(emptyList())
val postLikers: StateFlow<List<PostAllLikerResponse>> = _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<List<CommentResponse>>(emptyList())
val commentsUiState: StateFlow<List<CommentResponse>> = _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>(FeedUiState.Loading)
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
private val pageSize = 20
private var isLoadingMore = false

Loading…
Cancel
Save