Browse Source

10/19/2026

master
osman160128 1 week ago
parent
commit
a2ae9c4b42
  1. 1
      app/build.gradle.kts
  2. 6
      app/src/main/AndroidManifest.xml
  3. 450
      app/src/main/java/com/example/dattingapp/FriendProfile.kt
  4. 51
      app/src/main/java/com/example/dattingapp/LikeANdMatchActivity.kt
  5. 224
      app/src/main/java/com/example/dattingapp/MatchesActivity.kt
  6. 174
      app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt
  7. 26
      app/src/main/java/com/example/dattingapp/dataclass/DataClass.kt
  8. 108
      app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt
  9. 591
      app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt
  10. 26
      app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt
  11. 329
      app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt
  12. 167
      app/src/main/java/com/example/dattingapp/homes/MyProfile.kt
  13. 35
      app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt
  14. 45
      app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt
  15. 1
      app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt
  16. 234
      app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt
  17. 26
      app/src/main/java/com/example/dattingapp/homes/profile/UserResponseData.kt
  18. 1
      app/src/main/res/values/strings.xml

1
app/build.gradle.kts

@ -90,6 +90,7 @@ dependencies {
// For Hilt ViewModel support in Jetpack Compose (if using Compose) // For Hilt ViewModel support in Jetpack Compose (if using Compose)
implementation("androidx.hilt:hilt-navigation-compose:1.2.0") implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
implementation("io.coil-kt:coil-video:2.6.0")

6
app/src/main/AndroidManifest.xml

@ -139,6 +139,12 @@
android:label="@string/title_activity_show_posting" android:label="@string/title_activity_show_posting"
android:theme="@style/Theme.DattingAPP" android:theme="@style/Theme.DattingAPP"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity
android:name=".MatchesActivity"
android:exported="false"
android:label="@string/title_activity_matches"
android:theme="@style/Theme.DattingAPP"
android:windowSoftInputMode="adjustResize" />
</application> </application>
</manifest> </manifest>

450
app/src/main/java/com/example/dattingapp/FriendProfile.kt

@ -84,9 +84,6 @@ class FriendProfile : ComponentActivity() {
} }
} }
when (val state = profileUiState) { when (val state = profileUiState) {
is FriendProfileUiState.Loading -> { is FriendProfileUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -125,40 +122,53 @@ fun FriendProfileScreen(
onStarClick: () -> Unit = {}, onStarClick: () -> Unit = {},
onCancelClick: () -> Unit = {} onCancelClick: () -> Unit = {}
) { ) {
val likedViewModel: LikedViewModel = viewModel() val likedViewModel: LikedViewModel = viewModel()
val matchesViewModel: MatchesViewModel = viewModel()
// ✅ collect each state separately // Collect response states
val likesState by likedViewModel.likesUiState.collectAsState() // who liked me val likesState by likedViewModel.likesUiState.collectAsState() // Users who liked me
val likedState by likedViewModel.likedUiState.collectAsState() // I liked val likedState by likedViewModel.likedUiState.collectAsState() // Users I liked
val matchesState by likedViewModel.matchesUiState.collectAsState() // matches val matchesState by matchesViewModel.state.collectAsState() // Match list from server
// ✅ load all three lists once // Fetch lists once
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
likedViewModel.likedResponse(context, "Likes") likedViewModel.likedResponse(context, "Likes")
likedViewModel.likedResponse(context, "Liked") likedViewModel.likedResponse(context, "Liked")
likedViewModel.likedResponse(context, "Matches") matchesViewModel.loadMatches(context)
} }
// ✅ turn each into a set of ids // Extract ID sets
val iLikedIds = (likesState as? LikedUiState.Success) val iLikedIds = (likesState as? LikedUiState.Success)
?.LikeData?.map { it.id }?.toSet() ?: emptySet() ?.LikeData?.map { it.id }?.toSet() ?: emptySet()
val likedMeIds = (likedState as? LikedUiState.Success) val likedMeIds = (likedState as? LikedUiState.Success)
?.LikeData?.map { it.id }?.toSet() ?: emptySet() ?.LikeData?.map { it.id }?.toSet() ?: emptySet()
val matchIds = (matchesState as? LikedUiState.Success) val matchIds = (matchesState as? MatchesUiState.Success)
?.LikeData?.map { it.id }?.toSet() ?: emptySet() ?.matches?.map { it.user?.id }?.toSet() ?: emptySet()
// ✅ the three booleans for the button row // Base relationship status flags
val isMatch = friendProfileData.id in matchIds
val heLikedMe = friendProfileData.id in likedMeIds val heLikedMe = friendProfileData.id in likedMeIds
val iLikedHim = friendProfileData.id in iLikedIds val iLikedHim = friendProfileData.id in iLikedIds
// ✅ add this at the top of FriendProfileScreen, next to your other states // Track local tap in current screen session
var localLiked by rememberSaveable { mutableStateOf(false) } var localLiked by rememberSaveable { mutableStateOf(false) }
// Like is disabled if: I liked them before (server) OR I just clicked Like now // =========================================================================
// 2-STEP MATCH EVALUATION LOGIC
// =========================================================================
// STEP 1: Check online match list first
val isOnlineMatch = friendProfileData.id in matchIds
// STEP 2: If NOT an online match, check if he liked me + local like action
val isLocalMatch = !isOnlineMatch && heLikedMe && localLiked
// Final match decision
val isMatched = isOnlineMatch || isLocalMatch
// Derived button flags
val isMessageClickable = isMatched
val showLikeAndSuperLike = !isMatched
val likeDisabled = iLikedHim || localLiked val likeDisabled = iLikedHim || localLiked
val age = remember(friendProfileData.birth_date) { val age = remember(friendProfileData.birth_date) {
@ -265,7 +275,7 @@ fun FriendProfileScreen(
modifier = Modifier.size(10.dp) modifier = Modifier.size(10.dp)
) )
Text( Text(
text = "${friendProfileData.county?: 0}% Match", text = "${friendProfileData.county ?: 0}% Match",
color = Color.White, color = Color.White,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@ -310,7 +320,9 @@ fun FriendProfileScreen(
) )
} }
//action button // =========================================================================
// ACTION BUTTON ROW WITH EVALUATED MATCH STATES
// =========================================================================
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -318,21 +330,20 @@ fun FriendProfileScreen(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Message — card style, no background color // 💬 Message Button — Enabled ONLY when matched
// Message — enabled only when matched, grayed out otherwise
Card( Card(
onClick = onMessageClick, onClick = onMessageClick,
enabled = isMatch, // ✅ disabled when not matched enabled = isMessageClickable,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(48.dp), .height(48.dp),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (isMatch) Color.White else Color(0xFFF0F0F0), containerColor = if (isMessageClickable) Color.White else Color(0xFFF0F0F0),
disabledContainerColor = Color(0xFFF0F0F0) disabledContainerColor = Color(0xFFF0F0F0)
), ),
elevation = CardDefaults.cardElevation( elevation = CardDefaults.cardElevation(
defaultElevation = if (isMatch) 4.dp else 0.dp defaultElevation = if (isMessageClickable) 4.dp else 0.dp
) )
) { ) {
Row( Row(
@ -343,7 +354,7 @@ fun FriendProfileScreen(
Icon( Icon(
imageVector = Icons.Default.ChatBubble, imageVector = Icons.Default.ChatBubble,
contentDescription = "Message", contentDescription = "Message",
tint = if (isMatch) textDark else Color(0xFFBDBDBD), // gray when disabled tint = if (isMessageClickable) textDark else Color(0xFFBDBDBD),
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@ -351,75 +362,86 @@ fun FriendProfileScreen(
text = "Message", text = "Message",
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (isMatch) textDark else Color(0xFFBDBDBD) // gray when disabled color = if (isMessageClickable) textDark else Color(0xFFBDBDBD)
) )
} }
} }
// ❤️ Like & ⭐ Super Like Buttons — Hidden when matched
// ❤️ Like — red ICON when the user liked ME, normal otherwise if (showLikeAndSuperLike) {
// hidden when matched // ❤️ Like Button
Card(
// ❤️ Like — RED background if user liked me, DISABLED if I liked them onClick = {
Card( onLikeClick()
onClick = { handleLikeAndDislikeAction(context, "like", friendProfileData, swipeViewModel)
onLikeClick() localLiked = true
handleLikeAndDislikeAction(context, "right", friendProfileData, swipeViewModel) matchesViewModel.loadMatches(context)
localLiked = true // ✅ disable after first click },
}, enabled = !likeDisabled,
enabled = !likeDisabled, modifier = Modifier.size(48.dp),
modifier = Modifier.size(48.dp), shape = RoundedCornerShape(10.dp),
shape = RoundedCornerShape(10.dp), colors = CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor = if (heLikedMe) Color(0xFFE53935) else Color.White,
containerColor = if (heLikedMe) Color(0xFFE53935) else Color.White, disabledContainerColor = if (heLikedMe) Color(0xFFE53935) else Color(0xFFF0F0F0)
disabledContainerColor = if (heLikedMe) Color(0xFFE53935) else Color(0xFFF0F0F0) ),
), elevation = CardDefaults.cardElevation(
elevation = CardDefaults.cardElevation( defaultElevation = if (likeDisabled) 0.dp else 2.dp
defaultElevation = if (likeDisabled) 0.dp else 2.dp
)
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Like",
tint = when {
heLikedMe -> Color.White // white heart on red background
likeDisabled -> Color(0xFFBDBDBD) // gray when disabled
else -> textDark // normal
},
modifier = Modifier.size(20.dp)
) )
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Like",
tint = when {
heLikedMe -> Color.White
likeDisabled -> Color(0xFFBDBDBD)
else -> textDark
},
modifier = Modifier.size(20.dp)
)
}
} }
}
// Super Like — card style // ⭐ Super Like (Star) Button
Card( Card(
onClick = { onClick = {
onStarClick() onStarClick()
handleLikeAndDislikeAction(context, "right", friendProfileData, swipeViewModel) handleLikeAndDislikeAction(context, "like", friendProfileData, swipeViewModel)
}, localLiked = true
modifier = Modifier.size(48.dp), matchesViewModel.loadMatches(context)
shape = RoundedCornerShape(10.dp), },
colors = CardDefaults.cardColors(containerColor = Color.White), enabled = !likeDisabled,
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) modifier = Modifier.size(48.dp),
) { shape = RoundedCornerShape(10.dp),
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { colors = CardDefaults.cardColors(
Icon( containerColor = Color.White,
imageVector = Icons.Default.Star, disabledContainerColor = Color(0xFFF0F0F0)
contentDescription = "Favorite", ),
tint = textDark, elevation = CardDefaults.cardElevation(
modifier = Modifier.size(20.dp) defaultElevation = if (likeDisabled) 0.dp else 2.dp
) )
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Super Like",
tint = if (likeDisabled) Color(0xFFBDBDBD) else textDark,
modifier = Modifier.size(20.dp)
)
}
} }
} }
// Cancel — card style, always visible // ❌ Cancel / Dislike Button — Resets everything back to initial state
// Cancel — clicking it re-enables the Like button
Card( Card(
onClick = { onClick = {
onCancelClick() onCancelClick()
handleLikeAndDislikeAction(context, "left", friendProfileData, swipeViewModel) handleLikeAndDislikeAction(context, "pass", friendProfileData, swipeViewModel)
localLiked = false // ✅ Like button works again localLiked = false
likedViewModel.likedResponse(context, "Likes")
likedViewModel.likedResponse(context, "Liked")
matchesViewModel.loadMatches(context)
}, },
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
@ -436,9 +458,10 @@ fun FriendProfileScreen(
} }
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// User Posts Grid Section below Action Row // User Posts Section
Text( Text(
text = "Posts", text = "Posts",
fontSize = 18.sp, fontSize = 18.sp,
@ -450,16 +473,12 @@ fun FriendProfileScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
StaggeredFriendPostsGrid(posts = friendProfileData.posts) StaggeredFriendPostsGrid(posts = friendProfileData.posts)
} }
} }
} }
@Composable @Composable
fun StaggeredFriendPostsGrid(posts: List<FriendPost>) { fun StaggeredFriendPostsGrid(posts: List<FriendPost>) {
// Distribute posts into two columns
val columns = remember(posts) { val columns = remember(posts) {
val col1 = mutableListOf<FriendPost>() val col1 = mutableListOf<FriendPost>()
val col2 = mutableListOf<FriendPost>() val col2 = mutableListOf<FriendPost>()
@ -475,24 +494,17 @@ fun StaggeredFriendPostsGrid(posts: List<FriendPost>) {
} }
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(3.dp) horizontalArrangement = Arrangement.spacedBy(3.dp)
) { ) {
// Column 1
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
columns.first.forEachIndexed { idx, post -> columns.first.forEachIndexed { idx, post ->
// Apply a pattern of aspect ratios to create the staggered effect
// E.g., Alternating tall and short items
val aspectRatio = if (idx % 2 == 0) 0.8f else 1.2f val aspectRatio = if (idx % 2 == 0) 0.8f else 1.2f
MasonryPostItem(post = post, aspectRatio = aspectRatio) MasonryPostItem(post = post, aspectRatio = aspectRatio)
} }
} }
// Column 2
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
columns.second.forEachIndexed { idx, post -> columns.second.forEachIndexed { idx, post ->
// Apply a different pattern (or offset) to the second column
// E.g., Offsetting the alternating tall and short items
val aspectRatio = if (idx % 2 == 0) 1.2f else 0.8f val aspectRatio = if (idx % 2 == 0) 1.2f else 0.8f
MasonryPostItem(post = post, aspectRatio = aspectRatio) MasonryPostItem(post = post, aspectRatio = aspectRatio)
} }
@ -502,7 +514,7 @@ fun StaggeredFriendPostsGrid(posts: List<FriendPost>) {
@Composable @Composable
fun MasonryPostItem(post: FriendPost, aspectRatio: Float) { fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
val context = LocalContext.current // Get context to launch the activity val context = LocalContext.current
val primaryMedia = post.media.firstOrNull()?.url val primaryMedia = post.media.firstOrNull()?.url
val isImage = post.post_type.equals("image", ignoreCase = true) || !primaryMedia.isNullOrEmpty() val isImage = post.post_type.equals("image", ignoreCase = true) || !primaryMedia.isNullOrEmpty()
@ -514,20 +526,14 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
.aspectRatio(aspectRatio) .aspectRatio(aspectRatio)
.clickable { .clickable {
val intent = Intent(context, ShowPostingActivity::class.java).apply { val intent = Intent(context, ShowPostingActivity::class.java).apply {
val postID =post.id val postID = post.id
Log.d("Post_ID","$postID") Log.d("Post_ID", "$postID")
putExtra("Post_ID", postID) putExtra("Post_ID", postID)
} }
context.startActivity(intent) context.startActivity(intent)
} }
) { ) {
Box( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier
.fillMaxSize()
// .background(
// if (isImage) Color.DarkGray else getMasonryGradient(post.post_type)
// )
) {
if (isImage && !primaryMedia.isNullOrEmpty()) { if (isImage && !primaryMedia.isNullOrEmpty()) {
AsyncImage( AsyncImage(
model = primaryMedia, model = primaryMedia,
@ -536,7 +542,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
// Dark Gradient Scrim for text readability over images
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -548,7 +553,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
) )
) )
// Image metadata overlay (Feeling or Status snippet)
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
@ -579,7 +583,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
} }
} }
} else { } else {
// Text / Status / Vibe Card Design
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -614,7 +617,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
} }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Feeling tag if available
if (!post.feeling.isNullOrBlank()) { if (!post.feeling.isNullOrBlank()) {
Surface( Surface(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(6.dp),
@ -631,7 +633,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
} }
} }
// Main Content / Status Line
Text( Text(
text = post.content ?: "", text = post.content ?: "",
color = Color.White, color = Color.White,
@ -643,7 +644,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
) )
} }
// Footer Likes
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp) horizontalArrangement = Arrangement.spacedBy(2.dp)
@ -666,7 +666,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
} }
} }
private fun handleLikeAndDislikeAction( private fun handleLikeAndDislikeAction(
context: Context, context: Context,
direction: String, direction: String,
@ -688,232 +687,3 @@ private fun calculateAge(birthDateStr: String?): Int? {
null null
} }
} }
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun FriendProfileScreenPreview() {
DattingAPPTheme {
val mockProfileResponse = FriendProfileResponse(
id = 101,
full_name = "Elena Rostova",
gender = "Female",
birth_date = "1999-05-14",
city = "San Francisco",
county = "San Francisco County",
country = "USA",
relationship_goal = "Long-term relationship",
bio = "Always looking for the best flat white in the city. Amateur photographer, professional dog petter.",
profile_image_url = "https://via.placeholder.com/150",
video_url = null,
distance_km = 2.5,
compatibility_percentage = 98,
media = emptyList(),
interests = listOf("Travel", "Photography", "Coffee"),
posts = emptyList() // Posts are handled by postUiState in your screen
)
val mockPosts = listOf(
PostResponse(
id = 1,
content = "Cupidatat fugiat voluptate",
postType = "image",
author = PostAuthor(
id = 101,
fullName = "Elena Rostova",
city = "San Francisco",
country = "USA",
compatibilityPercentage = 98
),
likeCount = 12,
commentCount = 2,
likedByMe = false,
media = listOf("https://via.placeholder.com/300"),
createdAt = "2026-10-09T03:19:49"
),
PostResponse(
id = 2,
content = "Museum hopping & iced matcha 🍵🎨",
postType = "vibe",
author = PostAuthor(
id = 101,
fullName = "Elena Rostova",
city = "San Francisco",
country = "USA",
compatibilityPercentage = 98
),
likeCount = 34,
commentCount = 5,
likedByMe = true,
media = emptyList(),
createdAt = "2026-10-10T02:00:00"
),
PostResponse(
id = 3,
content = "Friday night dancing 🔮✨",
postType = "music",
author = PostAuthor(
id = 101,
fullName = "Elena Rostova",
city = "San Francisco",
country = "USA",
compatibilityPercentage = 98
),
likeCount = 45,
commentCount = 8,
likedByMe = false,
media = emptyList(),
createdAt = "2026-10-11T05:00:00"
)
)
FriendProfileScreen(
friendProfileData = mockProfileResponse,
context = LocalContext.current,
postUiState = PostUiState.Success(mockPosts),
onBackClick = {},
onMoreClick = {},
onMessageClick = {},
onLikeClick = {},
onStarClick = {},
onCancelClick = {}
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun StaggeredFriendPostsGridPreview() {
DattingAPPTheme {
val mockAuthor = FriendAuthor(
id = 1,
full_name = "Mock User",
city = "Mock City",
country = "Mock Country",
compatibility_percentage = 90
)
val mockPosts = listOf(
// Image Post - Aspect Ratio 0.75f (Tall)
FriendPost(
id = 1,
content = "Beautiful sunset!",
post_type = "image",
content_format = "plain",
feeling = "peaceful",
location = null,
author = mockAuthor,
like_count = 120,
comment_count = 15,
liked_by_me = false,
media = listOf(
FrienPostMedia(
id = 1,
media_type = "image",
mime_type = "image/jpeg",
size_bytes = 1024,
position = 0,
url = "https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwyMDkyMnwwfDF8c2VhcmNofDF8fHN1bnNldHxlbnwwfHx8fDE2NzQwMzUyMzA&ixlib=rb-1.2.1&q=80&w=400"
)
),
created_at = "2023-10-27T10:00:00Z"
),
// Text Post - Aspect Ratio 1.0f (Square)
FriendPost(
id = 2,
content = "Thinking about Kotlin Coroutines...",
post_type = "text",
content_format = "plain",
feeling = "thoughtful",
location = null,
author = mockAuthor,
like_count = 45,
comment_count = 5,
liked_by_me = true,
media = emptyList(),
created_at = "2023-10-27T09:30:00Z"
),
// Music Post - Aspect Ratio 1.1f (Slightly Wide)
FriendPost(
id = 3,
content = "Now Playing: 'Bohemian Rhapsody' by Queen",
post_type = "music",
content_format = "plain",
feeling = "energetic",
location = null,
author = mockAuthor,
like_count = 89,
comment_count = 10,
liked_by_me = false,
media = emptyList(),
created_at = "2023-10-27T09:00:00Z"
),
// Image Post - Aspect Ratio 1.25f (Wide)
FriendPost(
id = 4,
content = "Coffee time!",
post_type = "image",
content_format = "plain",
feeling = "happy",
location = null,
author = mockAuthor,
like_count = 210,
comment_count = 25,
liked_by_me = false,
media = listOf(
FrienPostMedia(
id = 2,
media_type = "image",
mime_type = "image/png",
size_bytes = 2048,
position = 0,
url = "https://images.unsplash.com/photo-1509042239860-f550ce710b93?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwyMDkyMnwwfDF8c2VhcmNofDF8fGNvZmZlZXxlbnwwfHx8fDE2NzQwMzUzMzA&ixlib=rb-1.2.1&q=80&w=400"
)
),
created_at = "2023-10-27T08:30:00Z"
),
// Vibe Post - Aspect Ratio 0.8f (Slightly Tall)
FriendPost(
id = 5,
content = "Just enjoying the quiet evening.",
post_type = "vibe",
content_format = "plain",
feeling = "calm",
location = null,
author = mockAuthor,
like_count = 60,
comment_count = 8,
liked_by_me = true,
media = emptyList(),
created_at = "2023-10-27T08:00:00Z"
),
// Image Post - Aspect Ratio 0.7f (Very Tall)
FriendPost(
id = 6,
content = "City lights",
post_type = "image",
content_format = "plain",
feeling = "amazed",
location = null,
author = mockAuthor,
like_count = 150,
comment_count = 18,
liked_by_me = false,
media = listOf(
FrienPostMedia(
id = 3,
media_type = "image",
mime_type = "image/jpeg",
size_bytes = 3072,
position = 0,
url = "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwyMDkyMnwwfDF8c2VhcmNofDF8fGNpdHklMjBsaWdodHN8ZW58MHx8fHwxNjc0MDM1NDMw&ixlib=rb-1.2.1&q=80&w=400"
)
),
created_at = "2023-10-27T07:30:00Z"
)
)
StaggeredFriendPostsGrid(posts = mockPosts)
}
}

51
app/src/main/java/com/example/dattingapp/LikeANdMatchActivity.kt

@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.LocationOn
@ -45,7 +44,6 @@ import com.example.dattingapp.ui.theme.DattingAPPTheme
import java.time.LocalDate import java.time.LocalDate
import java.time.Period import java.time.Period
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.jvm.java
class LikeANdMatchActivity : ComponentActivity() { class LikeANdMatchActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -58,14 +56,12 @@ class LikeANdMatchActivity : ComponentActivity() {
val context = LocalContext.current val context = LocalContext.current
val type = intent.getStringExtra("TYPE") ?: "Likes" val type = intent.getStringExtra("TYPE") ?: "Likes"
// Dynamically collect the correct StateFlow based on the 'type'
val uiState by when (type) { val uiState by when (type) {
"Likes" -> likedViewModel.likesUiState.collectAsState() "Likes" -> likedViewModel.likesUiState.collectAsState()
"Liked" -> likedViewModel.likedUiState.collectAsState() "Liked" -> likedViewModel.likedUiState.collectAsState()
else -> likedViewModel.matchesUiState.collectAsState() else -> likedViewModel.matchesUiState.collectAsState()
} }
// Trigger network call once when screen loads
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
likedViewModel.likedResponse(context, type) likedViewModel.likedResponse(context, type)
} }
@ -76,7 +72,6 @@ class LikeANdMatchActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
TopAppBar( TopAppBar(
title = { title = {
// Display dynamic title based on the page type
Text( Text(
text = when (type) { text = when (type) {
"Likes" -> "My Likes" "Likes" -> "My Likes"
@ -142,10 +137,13 @@ class LikeANdMatchActivity : ComponentActivity() {
} }
} }
} }
// 1. Define ProfileCard FIRST so ProfileGrid can reference it without issue
@Composable @Composable
fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Modifier = Modifier, fun ProfileCard(
onNameClick: () -> Unit={}, context: Context,
likedProfile: LikedUserResponse,
modifier: Modifier = Modifier,
onNameClick: () -> Unit = {},
) { ) {
Card( Card(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
@ -153,33 +151,29 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
Column( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { .clickable {
// Create an intent to launch your target activity (e.g., FriendProfileActivity)
val intent = Intent(context, FriendProfile::class.java).apply { val intent = Intent(context, FriendProfile::class.java).apply {
putExtra("USER_ID", likedProfile.id) // Pass the user ID here putExtra("USER_ID", likedProfile.id)
} }
context.startActivity(intent) context.startActivity(intent)
onNameClick()
onNameClick() } }
) { ) {
// Use profile_image_url or fallback to the first media item // FIX: media can be null from the server, so use ?. here
AsyncImage( AsyncImage(
model = likedProfile.profile_image_url?.takeIf { it.isNotBlank() } model = likedProfile.profile_image_url?.takeIf { it.isNotBlank() }
?: likedProfile.media.firstOrNull(), ?: likedProfile.media?.firstOrNull(),
contentDescription = likedProfile.full_name, contentDescription = likedProfile.full_name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
// Gradient Overlay
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -191,7 +185,6 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
) )
) )
// Match Badge
Surface( Surface(
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.9f), color = Color.White.copy(alpha = 0.9f),
@ -211,7 +204,7 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
modifier = Modifier.size(12.dp) modifier = Modifier.size(12.dp)
) )
Text( Text(
text = "${likedProfile.compatibility_percentage}%", text = "${likedProfile.compatibility_percentage ?: 0}%",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color(0xFF2D3436) color = Color(0xFF2D3436)
@ -219,23 +212,21 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
} }
} }
// Name, Age & Distance
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
// Makes just this text section clickable
.padding(8.dp) .padding(8.dp)
){ ) {
val displayName = likedProfile.full_name?.ifBlank { "User" } val displayName = likedProfile.full_name?.ifBlank { "User" } ?: "User"
val age = remember(likedProfile.birth_date) { val age = remember(likedProfile.birth_date) {
calculateAge(likedProfile.birth_date) calculateAge(likedProfile.birth_date)
} }
val nameAndAge = if (age != null) "$displayName, $age" else displayName val nameAndAge = if (age != null) "$displayName, $age" else displayName
Log.d("user","$likedProfile.id") Log.d("user", "${likedProfile.id}")
Text( Text(
text = nameAndAge?:"", text = nameAndAge,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 14.sp fontSize = 14.sp
@ -262,7 +253,6 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
} }
} }
// 2. Define ProfileGrid AFTER ProfileCard
@Composable @Composable
fun ProfileGrid( fun ProfileGrid(
context: Context, context: Context,
@ -274,7 +264,7 @@ fun ProfileGrid(
) { ) {
if (likedResponse.isEmpty() && !isLoading) { if (likedResponse.isEmpty() && !isLoading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No liked profiles found.", color = Color.Gray) Text("No profiles found.", color = Color.Gray)
} }
return return
} }
@ -287,12 +277,11 @@ fun ProfileGrid(
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize()
) { ) {
items(likedResponse) { profile -> items(likedResponse) { profile ->
ProfileCard(context,likedProfile = profile) ProfileCard(context, likedProfile = profile)
} }
} }
} }
// Helper to calculate age from birth date string (e.g., "1999-05-15")
private fun calculateAge(birthDateStr: String?): Int? { private fun calculateAge(birthDateStr: String?): Int? {
if (birthDateStr.isNullOrBlank()) return null if (birthDateStr.isNullOrBlank()) return null
return try { return try {
@ -329,7 +318,7 @@ fun ProfileGridPreview() {
DattingAPPTheme { DattingAPPTheme {
ProfileGrid( ProfileGrid(
context=context, context = context,
likedResponse = sampleLikedUsers, likedResponse = sampleLikedUsers,
isLoading = false, isLoading = false,
errorMessage = null errorMessage = null

224
app/src/main/java/com/example/dattingapp/MatchesActivity.kt

@ -0,0 +1,224 @@
package com.example.dattingapp
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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.homes.networkandapi.MatchesUiState
import com.example.dattingapp.homes.networkandapi.MatchesViewModel
import com.example.dattingapp.homes.profile.MatchListItem
import com.example.dattingapp.ui.theme.DattingAPPTheme
import java.time.LocalDate
import java.time.Period
import java.time.format.DateTimeFormatter
class MatchesActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DattingAPPTheme {
MatchesScreen(onBack = { finish() })
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MatchesScreen(
onBack: () -> Unit,
viewModel: MatchesViewModel = viewModel()
) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) { viewModel.loadMatches(context) }
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("My Matches", fontWeight = FontWeight.Bold, fontSize = 18.sp) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
when (val s = state) {
is MatchesUiState.Idle, is MatchesUiState.Loading -> CircularProgressIndicator()
is MatchesUiState.Error -> Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp)
) {
Text(s.message, color = MaterialTheme.colorScheme.error)
Button(onClick = { viewModel.loadMatches(context) }) { Text("Retry") }
}
is MatchesUiState.Success -> {
if (s.matches.isEmpty()) {
Text("No matches yet.", color = Color.Gray)
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(s.matches, key = { it.id }) { match ->
MatchCard(match)
}
}
}
}
}
}
}
}
@Composable
private fun MatchCard(match: MatchListItem) {
val context = LocalContext.current
val user = match.user ?: return
val name = user.full_name?.ifBlank { null } ?: "User"
val age = remember(user.birth_date) { matchAge(user.birth_date) }
val title = if (age != null) "$name, $age" else name
val matchedOn = match.matched_at?.take(10)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(16.dp))
.clickable {
// the OTHER person's id, so their profile opens
context.startActivity(
Intent(context, FriendProfile::class.java)
.putExtra("USER_ID", user.id)
)
}
) {
AsyncImage(
model = user.profile_image_url,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.7f)),
startY = 120f
)
)
)
Surface(
shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.9f),
modifier = Modifier
.padding(8.dp)
.align(Alignment.TopStart)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = null,
tint = Color(0xFF6C5CE7),
modifier = Modifier.size(12.dp)
)
Text(
text = "Match",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF2D3436)
)
}
}
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(10.dp)
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
if (matchedOn != null) {
Text(
text = "Matched $matchedOn",
color = Color.White.copy(alpha = 0.8f),
fontSize = 10.sp
)
}
}
}
}
}
private fun matchAge(birthDate: String?): Int? {
if (birthDate.isNullOrBlank()) return null
return try {
val d = LocalDate.parse(birthDate.take(10), DateTimeFormatter.ofPattern("yyyy-MM-dd"))
Period.between(d, LocalDate.now()).years
} catch (e: Exception) {
null
}
}

174
app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt

@ -10,7 +10,10 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -20,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material3.* import androidx.compose.material3.*
@ -40,11 +44,18 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.example.dattingapp.dataclass.Author import com.example.dattingapp.dataclass.Author
import com.example.dattingapp.dataclass.CommentResponse import com.example.dattingapp.dataclass.CommentResponse
import com.example.dattingapp.dataclass.Location import com.example.dattingapp.dataclass.Location
import com.example.dattingapp.dataclass.MediaItem
import com.example.dattingapp.dataclass.ShowPostResponse import com.example.dattingapp.dataclass.ShowPostResponse
import com.example.dattingapp.homes.networkandapi.PostAllLikerResponse import com.example.dattingapp.homes.networkandapi.PostAllLikerResponse
import com.example.dattingapp.homes.networkandapi.ShowPostUiState import com.example.dattingapp.homes.networkandapi.ShowPostUiState
@ -57,6 +68,22 @@ import com.example.dattingapp.ui.theme.TextPostTextColor
val AccentPink = Color(0xFFD81B60) val AccentPink = Color(0xFFD81B60)
val CommentBubbleBg = Color(0xFFF6F6F6) val CommentBubbleBg = Color(0xFFF6F6F6)
// Local URL resolver — self-contained so this screen works no matter which
// MediaItem class is imported. Turns relative server paths into absolute URLs
// and repairs the double-encoded %2F paths the server sometimes returns.
private const val MEDIA_BASE_URL = "https://melove.youthome.com.cn"
private fun resolveMediaUrl(raw: String?): String? {
val path = raw?.trim()?.takeIf { it.isNotBlank() } ?: return null
val resolved = when {
path.startsWith("https://", ignoreCase = true) -> path
path.startsWith("http://", ignoreCase = true) -> path
path.startsWith("//") -> "https:$path"
else -> "$MEDIA_BASE_URL/${path.trimStart('/')}"
}
return resolved.replace("%2F", "/")
}
class ShowPostingActivity : ComponentActivity() { class ShowPostingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -261,10 +288,11 @@ fun PostCard(
.padding(start = 10.dp, end = 10.dp) .padding(start = 10.dp, end = 10.dp)
.height(360.dp), .height(360.dp),
) { ) {
AsyncImage( // FIX: was AsyncImage(model = post.media.first().url) — showed ONLY
model = post.media.first().url, // the first media item, always as an image, with the raw URL.
contentDescription = "Post Media Image", // Now: swipeable pager over ALL media, images AND videos.
contentScale = ContentScale.Crop, PostMediaPager(
media = post.media,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@ -304,9 +332,9 @@ fun PostCard(
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { IconButton(
showPostViewModel.toggleLikeLocallyAndServer(context, post.id) onClick = { showPostViewModel.toggleLikeLocallyAndServer(context, post.id, likedByMe) }
}) { ) {
Icon( Icon(
imageVector = if (likedByMe) Icons.Filled.Favorite else Icons.Default.FavoriteBorder, imageVector = if (likedByMe) Icons.Filled.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Like", contentDescription = "Like",
@ -389,6 +417,128 @@ fun PostCard(
} }
} }
// ---------------------------------------------------------------------------
// NEW: swipeable media pager for the details screen — shows EVERY media item,
// renders images with Coil and videos with ExoPlayer.
// ---------------------------------------------------------------------------
@Composable
private fun PostMediaPager(media: List<MediaItem>, modifier: Modifier = Modifier) {
val pagerState = rememberPagerState(pageCount = { media.size })
Box(modifier) {
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val item = media[page]
val resolvedUrl = resolveMediaUrl(item.url)
if (item.mediaType == "video") {
if (resolvedUrl != null) {
PostVideoPlayer(
url = resolvedUrl,
isActive = pagerState.currentPage == page
)
} else {
Log.e("PostMediaPager", "Video item ${item.id} has no usable URL: ${item.url}")
}
} else {
AsyncImage(
model = resolvedUrl,
contentDescription = "Post media ${page + 1}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
// "1/3" counter badge — only when there's more than one media 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)
) {
Text(
text = "${pagerState.currentPage + 1}/${media.size}",
color = Color.White,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
@Composable
private fun PostVideoPlayer(url: String, isActive: Boolean) {
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
val player = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(ExoMediaItem.fromUri(url))
repeatMode = Player.REPEAT_MODE_ONE
prepare()
}
}
LaunchedEffect(isActive) {
if (isActive) player.play() else player.pause()
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing }
override fun onPlayerError(error: PlaybackException) {
Log.e(
"PostVideoPlayer",
"Playback failed for $url: ${error.errorCodeName} — ${error.message}",
error
)
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = false
}
},
modifier = Modifier.fillMaxSize()
)
// Tap layer above the PlayerView to toggle play/pause
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
if (player.isPlaying) player.pause() else player.play()
}
)
if (!isPlaying) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = "Play",
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(64.dp)
)
}
}
}
@Composable @Composable
fun CommentSection( fun CommentSection(
comments: List<CommentResponse>, comments: List<CommentResponse>,
@ -615,13 +765,21 @@ fun ShowPostScreenPreview() {
comment_count = 2, comment_count = 2,
liked_by_me = true, liked_by_me = true,
media = listOf( media = listOf(
com.example.dattingapp.dataclass.MediaItem( MediaItem(
id = 5, id = 5,
mediaType = "image", mediaType = "image",
mimeType = "image/png", mimeType = "image/png",
sizeBytes = 199619, sizeBytes = 199619,
position = 0, position = 0,
url = "https://images.unsplash.com/photo-1541167760496-1628856ab772" url = "https://images.unsplash.com/photo-1541167760496-1628856ab772"
),
MediaItem(
id = 6,
mediaType = "image",
mimeType = "image/png",
sizeBytes = 150000,
position = 1,
url = "https://images.unsplash.com/photo-1504674900247-0877df9cc836"
) )
), ),
createdAt = "2026-10-13T06:06:15" createdAt = "2026-10-13T06:06:15"

26
app/src/main/java/com/example/dattingapp/dataclass/DataClass.kt

@ -1,5 +1,6 @@
package com.example.dattingapp.dataclass package com.example.dattingapp.dataclass
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -39,15 +40,28 @@ data class Author(
@SerialName("compatibility_percentage") val compatibilityPercentage: Int? = 0 @SerialName("compatibility_percentage") val compatibilityPercentage: Int? = 0
) )
@Serializable
data class MediaItem( data class MediaItem(
val id: Long, val id: Long,
@SerialName("media_type") val mediaType: String, // "image" or "video" @SerializedName("media_type") val mediaType: String?, // ADD annotation
@SerialName("mime_type") val mimeType: String?, @SerializedName("mime_type") val mimeType: String?, // ADD annotation
@SerialName("size_bytes") val sizeBytes: Long?, @SerializedName("size_bytes") val sizeBytes: Long?, // ADD annotation
val position: Int?, val position: Int?,
val url: String val url: String?
) ) {
// ADD everything below ↓
val fullMediaUrl: String?
get() {
val path = url?.trim()?.takeIf { it.isNotBlank() } ?: return null
val resolved = when {
path.startsWith("https://", ignoreCase = true) -> path
path.startsWith("http://", ignoreCase = true) -> path
path.startsWith("//") -> "https:$path"
else -> "https://melove.youthome.com.cn/${path.trimStart('/')}"
}
return resolved.replace("%2F", "/")
}
}
@Serializable @Serializable

108
app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt

@ -55,6 +55,8 @@ import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import com.example.dattingapp.homes.networkandapi.CreatePostUiState import com.example.dattingapp.homes.networkandapi.CreatePostUiState
import com.example.dattingapp.homes.networkandapi.CreatePostViewModel import com.example.dattingapp.homes.networkandapi.CreatePostViewModel
import com.example.dattingapp.homes.networkandapi.PostMediaViewModel import com.example.dattingapp.homes.networkandapi.PostMediaViewModel
@ -84,11 +86,18 @@ fun CreateVibePostScreen(
val maxChar = 500 val maxChar = 500
val selectedMediaList = remember { mutableStateListOf<MediaContentItem>() } val selectedMediaList = remember { mutableStateListOf<MediaContentItem>() }
val post_type = remember(selectedMediaList) {
when { // FIX: was remember(selectedMediaList) { ... } — remember() does NOT observe
selectedMediaList.any { it is MediaContentItem.Video } -> "video" // snapshot state, and the list instance (the key) never changes, so post_type
selectedMediaList.any { it is MediaContentItem.Photo } -> "image" // was computed once ("text") and every photo/video post was sent as "text".
else -> "text" // derivedStateOf re-computes whenever the list contents change.
val post_type by remember {
derivedStateOf {
when {
selectedMediaList.any { it is MediaContentItem.Video } -> "video"
selectedMediaList.any { it is MediaContentItem.Photo } -> "image"
else -> "text"
}
} }
} }
@ -120,11 +129,19 @@ fun CreateVibePostScreen(
val postId = state.postData.id val postId = state.postData.id
Log.d("CreatePostDebug", "Post created with ID: $postId") Log.d("CreatePostDebug", "Post created with ID: $postId")
val errors = mutableListOf<String>() // Backend only accepts ONE file per request
selectedMediaList.toList().forEachIndexed { index, item -> // (POST /explore/posts/{id}/media), so upload each file
val err = postMediaViewModel.uploadMedia(context, postId, index, item.uri) // separately — but IN PARALLEL, not one after another.
if (err != null) errors.add("File ${index + 1}: $err") val errors = selectedMediaList.toList()
} .mapIndexed { index, item ->
async {
postMediaViewModel.uploadMedia(context, postId, index, item.uri)
?.let { "File ${index + 1}: $it" }
}
}
.awaitAll()
.filterNotNull()
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
Toast.makeText(context, errors.first(), Toast.LENGTH_LONG).show() Toast.makeText(context, errors.first(), Toast.LENGTH_LONG).show()
} }
@ -290,11 +307,10 @@ fun CreateVibePostScreen(
Button( Button(
enabled = !isPosting, // blocks double taps enabled = !isPosting, // blocks double taps
onClick = { onClick = {
if (textState.isBlank() && selectedMediaList.isEmpty()) { if (textState.isBlank() && selectedMediaList.isEmpty()) {
Toast.makeText(context, "Add text or at least one photo/video", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Add text or at least one photo/video", Toast.LENGTH_SHORT).show()
return@Button return@Button
} }
// ... build request and call createPost()
val postLocation = if (showLocationTag && latitude != null && longitude != null) { val postLocation = if (showLocationTag && latitude != null && longitude != null) {
Location( Location(
name = "Current Location", name = "Current Location",
@ -304,9 +320,9 @@ fun CreateVibePostScreen(
} else null } else null
val createPostRequest = CreatePostRequest( val createPostRequest = CreatePostRequest(
content = textState.trim().ifBlank { " " }, // ← the fix: " " instead of "" content = textState.trim().ifBlank { " " },
post_type = post_type, post_type = post_type,
content_format = "plain", // keep markdown, it works content_format = "plain",
feeling = selectedFeeling?.let { "${it.name} ${it.emoji}" }, feeling = selectedFeeling?.let { "${it.name} ${it.emoji}" },
location = postLocation location = postLocation
) )
@ -420,30 +436,40 @@ fun CreateVibePostScreen(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row( // FIX: location row is only shown when a location is actually
modifier = Modifier.fillMaxWidth(), // attached, and the X now actually removes it (it was a dead icon).
horizontalArrangement = Arrangement.SpaceBetween if (showLocationTag && latitude != null && longitude != null) {
) { Row(
Row(verticalAlignment = Alignment.CenterVertically) { modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = Color(0xFFE91E63),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Lat: $latitude, Lng: $longitude",
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
Icon( Icon(
imageVector = Icons.Outlined.LocationOn, imageVector = Icons.Default.Close,
contentDescription = null, contentDescription = "Remove location",
tint = Color(0xFFE91E63), tint = Color.Gray,
modifier = Modifier.size(20.dp) modifier = Modifier
) .size(16.dp)
Spacer(modifier = Modifier.width(8.dp)) .clickable(enabled = !isPosting) {
Text( latitude = null
text = "Lat: $latitude, Lng: $longitude", longitude = null
fontWeight = FontWeight.Medium, showLocationTag = false
fontSize = 13.sp }
) )
} }
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove location",
tint = Color.Gray,
modifier = Modifier.size(16.dp)
)
} }
} }
} }
@ -680,7 +706,11 @@ fun VideoPlayerItem(videoUri: Uri) {
} }
} }
DisposableEffect(Unit) { // FIX: was DisposableEffect(Unit) — it captured the player from the FIRST
// composition, so after a recomposition with a new URI the old player leaked
// and the new one was never released. Keying on exoPlayer ties release to
// the correct instance.
DisposableEffect(exoPlayer) {
onDispose { exoPlayer.release() } onDispose { exoPlayer.release() }
} }

591
app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt

@ -1,11 +1,13 @@
package com.example.dattingapp.homes package com.example.dattingapp.homes
import android.content.Intent import android.content.Intent
import android.util.Log
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.media3.common.MediaItem as ExoMediaItem import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -28,26 +30,34 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage import coil.compose.AsyncImage
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import com.example.dattingapp.R import com.example.dattingapp.R
import com.example.dattingapp.ShowPostingActivity import com.example.dattingapp.ShowPostingActivity
import com.example.dattingapp.dataclass.Author import com.example.dattingapp.dataclass.Author
import com.example.dattingapp.dataclass.Location import com.example.dattingapp.dataclass.Location
import com.example.dattingapp.dataclass.MediaItem import com.example.dattingapp.dataclass.MediaItem
import com.example.dattingapp.dataclass.ShowPostResponse import com.example.dattingapp.dataclass.ShowPostResponse
import com.example.dattingapp.homes.networkandapi.PostInteractionStore // NEW import com.example.dattingapp.homes.networkandapi.PostInteractionStore
import com.example.dattingapp.homes.networkandapi.rememberPostInteraction // NEW import com.example.dattingapp.homes.networkandapi.rememberPostInteraction
import com.example.dattingapp.homes.networkandapi.PostSearchUiState
import com.example.dattingapp.homes.networkandapi.PostSearchViewModel
import com.example.dattingapp.homes.networkandapi.ShowPostViewModel import com.example.dattingapp.homes.networkandapi.ShowPostViewModel
import com.example.dattingapp.homes.networkandapi.TokenManager import com.example.dattingapp.homes.networkandapi.TokenManager
import com.example.dattingapp.ui.theme.DattingAPPTheme import com.example.dattingapp.ui.theme.DattingAPPTheme
@ -76,40 +86,85 @@ fun VibeCheckHomeScreen(
isLoading: Boolean, isLoading: Boolean,
errorMessage: String?, errorMessage: String?,
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
searchViewModel: PostSearchViewModel = viewModel()
) { ) {
// NEW: put feed data into the shared store (ignored for likes while a like is pending) val context = LocalContext.current
// put feed data into the shared store (ignored for likes while a like is pending)
LaunchedEffect(feedResponse) { LaunchedEffect(feedResponse) {
feedResponse.forEach { feedResponse.forEach {
PostInteractionStore.setFromServer(it.id, it.liked_by_me, it.like_count, it.comment_count) PostInteractionStore.setFromServer(it.id, it.liked_by_me, it.like_count, it.comment_count)
} }
} }
val searchState by searchViewModel.state.collectAsState()
var showSearch by remember { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
VibeCheckTopAppBar(primaryColor) VibeCheckTopAppBar(
primaryColor = primaryColor,
onSearchClick = {
if (showSearch) searchViewModel.clearSearch() // closing clears the search
showSearch = !showSearch
}
)
} }
) { innerPadding -> ) { innerPadding ->
LazyColumn( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
.padding(horizontal = 16.dp), .padding(horizontal = 4.dp)
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
item { if (showSearch) {
SearchBarSection() Box(Modifier.padding(horizontal = 12.dp)) {
SearchPanel(
onSearch = { q, city, country, lat, lng, km ->
searchViewModel.search(context, q, city, country, lat, lng, km)
},
onClear = { searchViewModel.clearSearch() },
showClear = searchState !is PostSearchUiState.Idle
)
}
} }
items(feedResponse, key = { it.id }) { post -> when (val s = searchState) {
FeaturedEventCard( is PostSearchUiState.Loading -> Box(
post = post, Modifier
badgeColor = primaryColor .fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = primaryColor) }
is PostSearchUiState.Error -> Text(
text = s.message,
color = Color.Gray,
modifier = Modifier.padding(16.dp)
) )
}
item { // Search results: same post cards as the feed
Spacer(modifier = Modifier.height(16.dp)) is PostSearchUiState.Success -> LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(s.posts, key = { it.id }) { post ->
FeaturedEventCard(post = post, badgeColor = primaryColor)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
// No search active: normal feed
is PostSearchUiState.Idle -> LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(feedResponse, key = { it.id }) { post ->
FeaturedEventCard(post = post, badgeColor = primaryColor)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
} }
} }
} }
@ -117,7 +172,8 @@ fun VibeCheckHomeScreen(
@Composable @Composable
private fun VibeCheckTopAppBar( private fun VibeCheckTopAppBar(
primaryColor: Color primaryColor: Color,
onSearchClick: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val tokenManager = remember { TokenManager.getInstance(context) } val tokenManager = remember { TokenManager.getInstance(context) }
@ -160,7 +216,7 @@ private fun VibeCheckTopAppBar(
) )
} }
IconButton(onClick = { }) { IconButton(onClick = onSearchClick) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = "Search" contentDescription = "Search"
@ -169,55 +225,6 @@ private fun VibeCheckTopAppBar(
} }
} }
@Composable
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 = {
Icon(
imageVector = Icons.Default.Search,
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()
)
}
@Composable @Composable
private fun VibeFilterChipsSection( private fun VibeFilterChipsSection(
selectedColor: Color, selectedColor: Color,
@ -239,7 +246,7 @@ private fun VibeFilterChipsSection(
) { ) {
items(categories, key = { it.id }) { category -> items(categories, key = { it.id }) { category ->
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(12.dp),
color = if (category.isSelected) selectedColor else unselectedBg, color = if (category.isSelected) selectedColor else unselectedBg,
modifier = Modifier.height(32.dp) modifier = Modifier.height(32.dp)
) { ) {
@ -259,233 +266,305 @@ private fun VibeFilterChipsSection(
} }
} }
// ---------- helpers for the post card ----------
private val HASHTAG_REGEX = Regex("#[\\p{L}\\p{N}_]+")
private fun plural(n: Long, unit: String) = if (n == 1L) "1 $unit ago" else "$n ${unit}s ago"
/** "2 hours ago" style text. Returns null when createdAt is missing/unparseable. */
private fun timeAgo(createdAt: String?): String? {
if (createdAt.isNullOrBlank()) return null
return try {
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC") // change if your server sends local time
}
val then = fmt.parse(createdAt.take(19))?.time ?: return null
val minutes = (System.currentTimeMillis() - then).coerceAtLeast(0) / 60_000
val hours = minutes / 60
val days = hours / 24
when {
minutes < 1 -> "Just now"
minutes < 60 -> plural(minutes, "min")
hours < 24 -> plural(hours, "hour")
days < 7 -> plural(days, "day")
else -> plural(days / 7, "week")
}
} catch (e: Exception) {
null
}
}
/** Shows [value]; if it is null/blank shows [placeholder] in grey italic instead. */
@Composable
private fun OptionalText(
value: String?,
placeholder: String,
fontSize: TextUnit,
modifier: Modifier = Modifier,
fontWeight: FontWeight? = null,
color: Color = Color(0xFF1C1C1E),
maxLines: Int = Int.MAX_VALUE
) {
val missing = value.isNullOrBlank()
Text(
text = if (missing) placeholder else value!!,
color = if (missing) Color.Gray else color,
fontStyle = if (missing) FontStyle.Italic else FontStyle.Normal,
fontSize = fontSize,
fontWeight = if (missing) FontWeight.Normal else fontWeight,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
@Composable @Composable
private fun FeaturedEventCard( private fun FeaturedEventCard(
post: ShowPostResponse, post: ShowPostResponse,
badgeColor: Color, badgeColor: Color,
category: String? = null, // e.g. "Coffee Crawl Series" — no field in ShowPostResponse yet
showPostViewModel: ShowPostViewModel = viewModel() showPostViewModel: ShowPostViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
// NEW: live like/comment values shared with the details screen // live like/comment values shared with the details screen
val live = rememberPostInteraction(post.id) val live = rememberPostInteraction(post.id)
val likedByMe = live?.likedByMe ?: post.liked_by_me val likedByMe = live?.likedByMe ?: post.liked_by_me
val likeCount = live?.likeCount ?: post.like_count val likeCount = live?.likeCount ?: post.like_count
val commentCount = live?.commentCount ?: post.comment_count val commentCount = live?.commentCount ?: post.comment_count
val hasMedia = post.media.isNotEmpty() val hasMedia = post.media.isNotEmpty()
val tags = listOfNotNull(post.feeling, post.location?.name)
val contentColor = if (hasMedia) Color.White else TextPostTextColor // Hashtags are pulled out of the text; the body shows the text without them.
val shadowStyle = if (hasMedia) TextStyle(shadow = OverlayShadow) else TextStyle.Default val hashtags = remember(post.content) {
HASHTAG_REGEX.findAll(post.content.orEmpty()).map { it.value }.distinct().toList()
}
val bodyText = remember(post.content) {
post.content.orEmpty().replace(HASHTAG_REGEX, "").trim()
}
val openDetails: () -> Unit = {
val intent = Intent(context, ShowPostingActivity::class.java).apply {
putExtra("Post_ID", post.id)
}
context.startActivity(intent)
}
Card( Card(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
border = if (hasMedia) null else BorderStroke(1.dp, Color(0xFFEDEDED)), border = BorderStroke(1.dp, Color(0xFFEDEDED)),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = Color.White),
containerColor = if (hasMedia) Color.DarkGray else Color.White elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(360.dp) .clickable { openDetails() }
.clickable {
val intent = Intent(context, ShowPostingActivity::class.java).apply {
putExtra("Post_ID", post.id)
}
context.startActivity(intent)
}
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.padding(12.dp)) {
// 1. Content: swipeable media, or big centered text // ---- 1. Header: avatar, name, feeling, time · location ----
if (hasMedia) { Row(verticalAlignment = Alignment.CenterVertically) {
PostMediaPager(media = post.media, modifier = Modifier.fillMaxSize()) AsyncImage(
model = post.author?.profile_image_url,
// 2. Top scrim contentDescription = "Author image",
Box( placeholder = painterResource(id = R.drawable.locationpermissionbg),
error = painterResource(id = R.drawable.locationpermissionbg),
contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .size(40.dp)
.height(90.dp) .clip(CircleShape)
.align(Alignment.TopCenter)
.background(
Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.45f), Color.Transparent)
)
)
) )
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
OptionalText(
value = post.author?.full_name,
placeholder = "Unknown user",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier.weight(1f, fill = false)
)
Spacer(Modifier.width(6.dp))
OptionalText(
value = post.feeling?.let { "— feeling $it" },
placeholder = "No feeling",
fontSize = 12.sp,
color = Color.Gray,
maxLines = 1
)
}
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OptionalText(
value = timeAgo(post.createdAt),
placeholder = "Unknown time",
fontSize = 11.sp,
color = Color.Gray,
maxLines = 1
)
Text(" · ", color = Color.Gray, fontSize = 11.sp)
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = badgeColor,
modifier = Modifier.size(12.dp)
)
Spacer(Modifier.width(2.dp))
OptionalText(
value = post.location?.name,
placeholder = "No location",
fontSize = 11.sp,
color = Color.Gray,
maxLines = 1
)
}
}
}
// ---- 2. Text ----
Spacer(Modifier.height(10.dp))
OptionalText(
value = bodyText,
placeholder = "No description",
fontSize = 14.sp,
maxLines = 4
)
// 3. Bottom scrim // ---- 3. Media with category label (text-only posts skip this block) ----
if (hasMedia) {
Spacer(Modifier.height(10.dp))
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(200.dp)
.align(Alignment.BottomCenter) .clip(RoundedCornerShape(12.dp))
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.8f))
)
)
)
} else {
Text(
text = post.content.orEmpty(),
color = TextPostTextColor,
fontSize = if ((post.content?.length ?: 0) > 120) 18.sp else 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 8,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 32.dp)
)
}
// 4. Badge
Surface(
color = badgeColor,
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.padding(12.dp)
.align(Alignment.TopStart)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( PostMediaPager(
imageVector = when (post.postType) { media = post.media,
"video" -> Icons.Default.Videocam onVideoClick = openDetails,
"image" -> Icons.Default.Image modifier = Modifier.fillMaxSize()
else -> Icons.Default.TextFields
},
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = post.postType?.uppercase() ?: "POST",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
) )
Surface(
color = Color.Black.copy(alpha = 0.55f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Outlined.Image,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = category?.takeIf { it.isNotBlank() } ?: "No category",
color = Color.White,
fontStyle = if (category.isNullOrBlank()) FontStyle.Italic else FontStyle.Normal,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
} }
} }
// 5. Like / comment pillar // ---- 4. Hashtags ----
Column( Spacer(Modifier.height(10.dp))
modifier = Modifier if (hashtags.isEmpty()) {
.align(Alignment.BottomEnd) OptionalText(value = null, placeholder = "No hashtags", fontSize = 12.sp)
.padding(end = 12.dp, bottom = 16.dp) } else {
.clip(RoundedCornerShape(24.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
.padding(vertical = 4.dp, horizontal = 2.dp), items(hashtags) { tag ->
horizontalAlignment = Alignment.CenterHorizontally, Surface(color = lightChipBg, shape = RoundedCornerShape(8.dp)) {
verticalArrangement = Arrangement.spacedBy(16.dp) Text(
) { text = tag,
// Like + count color = badgeColor,
Column(horizontalAlignment = Alignment.CenterHorizontally) { fontSize = 11.sp,
IconButton(onClick = { fontWeight = FontWeight.Medium,
showPostViewModel.toggleLikeLocallyAndServer(context, post.id) modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
}) { )
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 = "$likeCount",
color = contentColor,
fontSize = 12.sp,
style = shadowStyle
)
} }
}
// Comment + count // ---- 5. Actions: like, comment, share ----
Column(horizontalAlignment = Alignment.CenterHorizontally) { Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = { showPostViewModel.toggleLikeLocallyAndServer(context, post.id, likedByMe) },
modifier = Modifier.size(32.dp)
) {
Icon( Icon(
imageVector = Icons.Outlined.ChatBubbleOutline, imageVector = if (likedByMe) Icons.Default.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = "Comment", contentDescription = "Like",
tint = contentColor, tint = if (likedByMe) Color.Red else Color.Gray,
modifier = Modifier.size(28.dp) modifier = Modifier.size(20.dp)
)
Text(
text = "$commentCount",
color = contentColor,
fontSize = 12.sp,
style = shadowStyle
) )
} }
} Text("$likeCount", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
// 6. Bottom info Spacer(Modifier.width(14.dp))
Column(
modifier = Modifier Icon(
.align(Alignment.BottomStart) imageVector = Icons.Outlined.ChatBubbleOutline,
.padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 72.dp) contentDescription = "Comment",
) { tint = Color.Gray,
Text( modifier = Modifier.size(20.dp)
text = post.author?.full_name ?: "Anonymous",
color = contentColor,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
style = shadowStyle
) )
Spacer(Modifier.width(4.dp))
Text("$commentCount", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
if (hasMedia && !post.content.isNullOrBlank()) { Spacer(Modifier.weight(1f))
Spacer(Modifier.height(4.dp))
Text(
text = post.content,
color = Color.White,
fontSize = 13.sp,
lineHeight = 17.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = shadowStyle
)
}
if (tags.isNotEmpty()) { Icon(
Spacer(Modifier.height(8.dp)) imageVector = Icons.Outlined.Share,
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { contentDescription = "Share",
tags.forEach { tag -> tint = Color.Gray,
Surface( modifier = Modifier.size(20.dp)
color = if (hasMedia) Color.Black.copy(alpha = 0.45f) )
else Color(0xFFF1F1F1),
shape = RoundedCornerShape(6.dp)
) {
Text(
text = tag,
color = contentColor,
fontSize = 10.sp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
} }
} }
} }
} }
@Composable @Composable
private fun PostMediaPager(media: List<MediaItem>, modifier: Modifier = Modifier) { private fun PostMediaPager(
media: List<MediaItem>,
onVideoClick: () -> Unit,
modifier: Modifier = Modifier
) {
val pagerState = rememberPagerState(pageCount = { media.size }) val pagerState = rememberPagerState(pageCount = { media.size })
Box(modifier) { Box(modifier) {
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val item = media[page] val item = media[page]
// Use the RESOLVED absolute URL — item.url is a relative path
// (and may contain broken %2F encoding), which neither Coil nor
// ExoPlayer can open.
val resolvedUrl = item.fullMediaUrl
if (item.mediaType == "video") { if (item.mediaType == "video") {
PostVideoPlayer( if (resolvedUrl != null) {
url = item.url, PostVideoPlayer(
isActive = pagerState.currentPage == page url = resolvedUrl,
) isActive = pagerState.currentPage == page,
onClick = onVideoClick
)
} else {
Log.e("PostMediaPager", "Video item ${item.id} has no usable URL: ${item.url}")
}
} else { } else {
AsyncImage( AsyncImage(
model = item.url, model = resolvedUrl,
contentDescription = "Post media ${page + 1}", contentDescription = "Post media ${page + 1}",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -513,7 +592,7 @@ private fun PostMediaPager(media: List<MediaItem>, modifier: Modifier = Modifier
} }
@Composable @Composable
private fun PostVideoPlayer(url: String, isActive: Boolean) { private fun PostVideoPlayer(url: String, isActive: Boolean, onClick: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) } var isPlaying by remember { mutableStateOf(false) }
@ -525,13 +604,23 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) {
} }
} }
// Start playback when this page becomes the visible one
LaunchedEffect(isActive) { LaunchedEffect(isActive) {
if (!isActive) player.pause() if (isActive) player.play() else player.pause()
} }
DisposableEffect(player) { DisposableEffect(player) {
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing } override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing }
// Surface playback errors in Logcat instead of failing silently
override fun onPlayerError(error: PlaybackException) {
Log.e(
"PostVideoPlayer",
"Playback failed for $url: ${error.errorCodeName} — ${error.message}",
error
)
}
} }
player.addListener(listener) player.addListener(listener)
onDispose { onDispose {
@ -540,12 +629,7 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) {
} }
} }
Box( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
modifier = Modifier
.fillMaxSize()
.clickable { if (player.isPlaying) player.pause() else player.play() },
contentAlignment = Alignment.Center
) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PlayerView(ctx).apply { PlayerView(ctx).apply {
@ -555,6 +639,21 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) {
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
// Tap layer ABOVE the PlayerView — PlayerView consumes touches, so the
// click must be handled here. Tapping the video opens the details
// screen, exactly like tapping an image or a text post.
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onClick()
}
)
if (!isPlaying) { if (!isPlaying) {
Icon( Icon(
imageVector = Icons.Default.PlayCircle, imageVector = Icons.Default.PlayCircle,

26
app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt

@ -23,6 +23,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -363,33 +364,40 @@ fun HomeScreen() {
// ========================================= // =========================================
// PROFILE // PROFILE
// ========================================= // =========================================
// =========================================
// PROFILE
// =========================================
4 -> { 4 -> {
// 1. Obtain instance of ViewModel and LocalContext
val profileViewModel: ProfileViewModel = viewModel() val profileViewModel: ProfileViewModel = viewModel()
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// 2. Collect state from ViewModel
val uiState by profileViewModel.profileUiState.collectAsState() val uiState by profileViewModel.profileUiState.collectAsState()
// 3. Trigger network call once when screen loads // Automatically reload profile data when entering this tab OR resuming from another activity
LaunchedEffect(Unit) { DisposableEffect(lifecycleOwner) {
profileViewModel.profileResponse(context) val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
profileViewModel.profileResponse(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
} }
// 4. Map UI state to Composable parameters
when (val state = uiState) { when (val state = uiState) {
is ProfileUiState.Loading, ProfileUiState.Idle -> { is ProfileUiState.Loading, ProfileUiState.Idle -> {
MyProfileScreen( MyProfileScreen(
profileResponse = null, profileResponse = null,
isLoading = true, isLoading = true,
errorMessage = null, errorMessage = null,
// onRetry = { viewModel.meetUserResponse(context) }
) )
} }
is ProfileUiState.Success -> { is ProfileUiState.Success -> {
MyProfileScreen( MyProfileScreen(
profileResponse = state.profileData, // or state.response depending on your data class naming profileResponse = state.profileData,
isLoading = false, isLoading = false,
errorMessage = null, errorMessage = null,
) )

329
app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt

@ -1,9 +1,16 @@
package com.example.dattingapp.homes package com.example.dattingapp.homes
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.location.LocationManager
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -11,6 +18,9 @@ import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Close
@ -24,21 +34,28 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.example.dattingapp.FriendProfile import com.example.dattingapp.FriendProfile
import com.example.dattingapp.homes.networkandapi.MeetUserProfile import com.example.dattingapp.homes.networkandapi.MeetUserProfile
import com.example.dattingapp.homes.networkandapi.SearchUiState
import com.example.dattingapp.homes.networkandapi.SearchViewModel
import com.example.dattingapp.homes.networkandapi.SwipeRequest import com.example.dattingapp.homes.networkandapi.SwipeRequest
import com.example.dattingapp.homes.networkandapi.SwipeViewModel import com.example.dattingapp.homes.networkandapi.SwipeViewModel
import com.example.dattingapp.homes.networkandapi.TokenManager import com.example.dattingapp.homes.networkandapi.TokenManager
@ -66,11 +83,25 @@ fun MeetDiscoveryScreen(
onSuperLikeClick: (MeetUserProfile) -> Unit = {}, onSuperLikeClick: (MeetUserProfile) -> Unit = {},
onRejectClick: (MeetUserProfile) -> Unit = {}, onRejectClick: (MeetUserProfile) -> Unit = {},
onLikeClick: (MeetUserProfile) -> Unit = {}, onLikeClick: (MeetUserProfile) -> Unit = {},
swipeViewModel: SwipeViewModel = viewModel() swipeViewModel: SwipeViewModel = viewModel(),
searchViewModel: SearchViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
var currentIndex by remember { mutableIntStateOf(0) } var currentIndex by remember { mutableIntStateOf(0) }
// ---- Search state ----
val searchState by searchViewModel.state.collectAsState()
var showSearchPanel by remember { mutableStateOf(false) }
// Restart the card stack whenever the search state changes
LaunchedEffect(searchState) { currentIndex = 0 }
// Search results replace the normal list while a search is active
val searchActive = searchState is SearchUiState.Success
val activeProfiles = (searchState as? SearchUiState.Success)?.results ?: profiles
val activeLoading = isLoading || searchState is SearchUiState.Loading
val activeError = (searchState as? SearchUiState.Error)?.message ?: errorMessage
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -112,7 +143,10 @@ fun MeetDiscoveryScreen(
color = BrandPurple color = BrandPurple
) )
IconButton(onClick = onSearchClick) { IconButton(onClick = {
showSearchPanel = !showSearchPanel
onSearchClick()
}) {
Icon( Icon(
imageVector = Icons.Outlined.Search, imageVector = Icons.Outlined.Search,
contentDescription = "Search", contentDescription = "Search",
@ -122,6 +156,17 @@ fun MeetDiscoveryScreen(
} }
} }
// SEARCH PANEL (toggled by the search icon)
if (showSearchPanel) {
SearchPanel(
onSearch = { q, city, country, lat, lng, km ->
searchViewModel.search(context, q, city, country, lat, lng, km)
},
onClear = { searchViewModel.clearSearch() },
showClear = searchState !is SearchUiState.Idle
)
}
// MAIN CONTENT CARD // MAIN CONTENT CARD
Box( Box(
modifier = Modifier modifier = Modifier
@ -131,20 +176,20 @@ fun MeetDiscoveryScreen(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when { when {
isLoading -> { activeLoading -> {
CircularProgressIndicator(color = BrandPink) CircularProgressIndicator(color = BrandPink)
} }
errorMessage != null -> { activeError != null -> {
Text( Text(
text = errorMessage, text = activeError,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
fontSize = 16.sp fontSize = 16.sp
) )
} }
profiles.isNotEmpty() && currentIndex < profiles.size -> { activeProfiles.isNotEmpty() && currentIndex < activeProfiles.size -> {
val currentProfile = profiles[currentIndex] val currentProfile = activeProfiles[currentIndex]
ProfileCardItem( ProfileCardItem(
profile = currentProfile, profile = currentProfile,
@ -168,7 +213,7 @@ fun MeetDiscoveryScreen(
else -> { else -> {
Text( Text(
text = "No more profiles available!", text = if (searchActive) "No more results" else "No more profiles available!",
fontSize = 16.sp, fontSize = 16.sp,
color = Color.Gray color = Color.Gray
) )
@ -179,6 +224,269 @@ fun MeetDiscoveryScreen(
} }
} }
// ==========================================
// SEARCH PANEL: normal search bar + small filter button
// ==========================================
@Composable
fun SearchPanel(
onSearch: (String?, String?, String?, Double?, Double?, Double?) -> Unit,
onClear: () -> Unit,
showClear: Boolean
) {
val context = LocalContext.current
var q by remember { mutableStateOf("") }
var city by remember { mutableStateOf("") }
var country by remember { mutableStateOf("") }
var km by remember { mutableStateOf("20") }
var lat by remember { mutableStateOf<Double?>(null) }
var lng by remember { mutableStateOf<Double?>(null) }
var showFilters by remember { mutableStateOf(false) }
val activeFilters = listOf(city.isNotBlank(), country.isNotBlank(), lat != null).count { it }
fun doSearch() = onSearch(q, city, country, lat, lng, km.toDoubleOrNull() ?: 20.0)
fun fetchLocation() {
try {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val loc = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.filter { lm.isProviderEnabled(it) }
.firstNotNullOfOrNull { lm.getLastKnownLocation(it) }
if (loc != null) {
lat = loc.latitude
lng = loc.longitude
} else {
Toast.makeText(context, "Unable to get location. Turn on GPS.", Toast.LENGTH_SHORT).show()
}
} catch (e: SecurityException) {
Toast.makeText(context, "Location permission not granted", Toast.LENGTH_SHORT).show()
}
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) fetchLocation() }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Normal search bar + small filter button beside it
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Compact search bar (40dp tall)
Row(
modifier = Modifier
.weight(1f)
.height(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFFEBEBEB))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
if (q.isEmpty()) {
Text(
text = "Search interests, e.g. hiking",
color = Color.Gray,
fontSize = 14.sp,
maxLines = 1
)
}
BasicTextField(
value = q,
onValueChange = { q = it },
singleLine = true,
textStyle = TextStyle(fontSize = 14.sp, color = Color(0xFF1E1E1E)),
cursorBrush = SolidColor(BrandPink),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { doSearch() }),
modifier = Modifier.fillMaxWidth()
)
}
if (q.isNotEmpty() || showClear) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Clear",
tint = Color.Gray,
modifier = Modifier
.size(18.dp)
.clickable {
q = ""
city = ""
country = ""
lat = null
lng = null
onClear()
}
)
}
}
// Small filter button (matches the bar height)
IconButton(
onClick = { showFilters = !showFilters },
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (showFilters || activeFilters > 0) BrandPink.copy(alpha = 0.15f)
else Color(0xFFEBEBEB)
)
) {
BadgedBox(
badge = {
if (activeFilters > 0) {
Badge(containerColor = BrandPink) { Text("$activeFilters") }
}
}
) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "Filters",
tint = BrandPink,
modifier = Modifier.size(20.dp)
)
}
}
}
// Filter options, shown only when the filter button is tapped
if (showFilters) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CompactField(
value = city,
onValueChange = { city = it },
placeholder = "City",
modifier = Modifier.weight(1f)
)
CompactField(
value = country,
onValueChange = { country = it },
placeholder = "Country",
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Small "Near me" pill
Row(
modifier = Modifier
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(if (lat != null) BrandPink.copy(alpha = 0.15f) else Color(0xFFEBEBEB))
.clickable {
if (lat != null) {
lat = null
lng = null
} else if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fetchLocation()
} else {
permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = if (lat != null) BrandPink else Color.Gray,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = "Near me",
fontSize = 13.sp,
color = if (lat != null) BrandPink else Color(0xFF555555)
)
}
if (lat != null) {
CompactField(
value = km,
onValueChange = { km = it.filter { c -> c.isDigit() || c == '.' } },
placeholder = "km",
keyboardType = KeyboardType.Number,
modifier = Modifier.width(70.dp)
)
}
Spacer(Modifier.weight(1f))
// Small Apply button
Button(
onClick = { doSearch(); showFilters = false },
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = 18.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = BrandPink)
) {
Text("Apply", fontSize = 13.sp)
}
}
}
}
}
@Composable
private fun CompactField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Text
) {
Box(
modifier = modifier
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color(0xFFEBEBEB))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
Text(placeholder, color = Color.Gray, fontSize = 13.sp, maxLines = 1)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = TextStyle(fontSize = 13.sp, color = Color(0xFF1E1E1E)),
cursorBrush = SolidColor(BrandPink),
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
modifier = Modifier.fillMaxWidth()
)
}
}
// ==========================================
// SWIPE CARD
// ==========================================
@Composable @Composable
private fun ProfileCardItem( private fun ProfileCardItem(
profile: MeetUserProfile, profile: MeetUserProfile,
@ -348,8 +656,7 @@ private fun ProfileCardItem(
text = "SUPER LIKE", text = "SUPER LIKE",
color = Color(0xFF3498DB), color = Color(0xFF3498DB),
alpha = superAlpha, alpha = superAlpha,
modifier = Modifier modifier = Modifier.align(Alignment.Center)
.align(Alignment.Center)
) )
// ACTION BUTTONS (Right Side) // ACTION BUTTONS (Right Side)
@ -515,7 +822,7 @@ private fun SwipeStamp(
modifier = modifier.graphicsLayer { this.alpha = alpha }, modifier = modifier.graphicsLayer { this.alpha = alpha },
color = Color.Transparent, color = Color.Transparent,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
border = androidx.compose.foundation.BorderStroke(3.dp, color) border = BorderStroke(3.dp, color)
) { ) {
Text( Text(
text = text, text = text,

167
app/src/main/java/com/example/dattingapp/homes/MyProfile.kt

@ -12,9 +12,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.TextSnippet import androidx.compose.material.icons.filled.TextSnippet
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -31,11 +34,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.decode.VideoFrameDecoder
import com.example.dattingapp.LikeANdMatchActivity import com.example.dattingapp.LikeANdMatchActivity
import com.example.dattingapp.MatchesActivity
import com.example.dattingapp.R import com.example.dattingapp.R
import com.example.dattingapp.ShowPostingActivity import com.example.dattingapp.ShowPostingActivity
import com.example.dattingapp.homes.networkandapi.PostInteractionStore // NEW import com.example.dattingapp.homes.networkandapi.PostInteractionStore // NEW
import com.example.dattingapp.homes.networkandapi.rememberPostInteraction // NEW
import com.example.dattingapp.homes.profile.Author import com.example.dattingapp.homes.profile.Author
import com.example.dattingapp.homes.profile.Media import com.example.dattingapp.homes.profile.Media
import com.example.dattingapp.homes.profile.Post import com.example.dattingapp.homes.profile.Post
@ -85,6 +92,14 @@ fun MyProfileScreen(
val deletedIds by PostInteractionStore.deletedIds.collectAsState() val deletedIds by PostInteractionStore.deletedIds.collectAsState()
val visiblePosts = (profileResponse?.posts ?: emptyList()).filter { it.id !in deletedIds } val visiblePosts = (profileResponse?.posts ?: emptyList()).filter { it.id !in deletedIds }
// NEW: seed the shared store so grid tiles show LIVE like/comment counts
// (likes/comments made on the details screen update the grid instantly)
LaunchedEffect(visiblePosts) {
visiblePosts.forEach {
PostInteractionStore.setFromServer(it.id, it.liked_by_me, it.like_count, it.comment_count)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -332,9 +347,7 @@ fun MyProfileScreen(
label = "Matches", label = "Matches",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onClick = {
val intent = Intent(context, LikeANdMatchActivity::class.java).apply { val intent = Intent(context, MatchesActivity ::class.java)
putExtra("TYPE", "Matches")
}
context.startActivity(intent) context.startActivity(intent)
onMatchClick() onMatchClick()
} }
@ -344,15 +357,27 @@ fun MyProfileScreen(
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
// ==================================================== // ====================================================
// POSTED SECTION // POSTED SECTION — header now includes the post count
// ==================================================== // ====================================================
Text( Row(
text = "Posted", modifier = Modifier
fontSize = 18.sp, .fillMaxWidth()
fontWeight = FontWeight.Bold, .padding(horizontal = 16.dp),
color = textDark, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 16.dp) verticalAlignment = Alignment.CenterVertically
) ) {
Text(
text = "Posted",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = textDark
)
Text(
text = "${visiblePosts.size} posts",
fontSize = 13.sp,
color = TextMuted
)
}
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
ProfilePostsGrid(posts = visiblePosts) // CHANGED: deleted posts are filtered out ProfilePostsGrid(posts = visiblePosts) // CHANGED: deleted posts are filtered out
@ -454,11 +479,75 @@ private fun ProfilePostsGrid(posts: List<Post>) {
} }
} }
// Small live like/comment row used on every tile
@Composable
private fun LikeCommentRow(post: Post) {
// Live values from the shared store; fall back to the values that came
// with the profile response.
val live = rememberPostInteraction(post.id)
val likeCount = live?.likeCount ?: post.like_count
val commentCount = live?.commentCount ?: post.comment_count
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Likes",
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(9.dp)
)
Text(
text = "$likeCount",
color = Color.White.copy(alpha = 0.8f),
fontSize = 8.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(
imageVector = Icons.Outlined.ChatBubbleOutline,
contentDescription = "Comments",
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(9.dp)
)
Text(
text = "$commentCount",
color = Color.White.copy(alpha = 0.8f),
fontSize = 8.sp
)
}
}
}
@Composable @Composable
private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) { private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
val context = LocalContext.current val context = LocalContext.current
val primaryMedia = post.media.firstOrNull()?.url
val isImage = post.post_type.equals("image", ignoreCase = true) || !primaryMedia.isNullOrEmpty() // NEW: first media item + type detection + total count
val firstMedia = post.media.firstOrNull()
val primaryMedia = firstMedia?.url
val mediaCount = post.media.size
val isVideo = firstMedia?.media_type.equals("video", ignoreCase = true)
val isImage = post.post_type.equals("image", ignoreCase = true) ||
post.post_type.equals("video", ignoreCase = true) ||
!primaryMedia.isNullOrEmpty()
// NEW: ImageLoader that can grab a FRAME out of a video file.
// Needs: implementation("io.coil-kt:coil-video:2.6.0") in app/build.gradle.kts
// It only extracts a still image for display — nothing is played here.
val videoAwareImageLoader = remember {
ImageLoader.Builder(context)
.components { add(VideoFrameDecoder.Factory()) }
.build()
}
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
@ -475,9 +564,12 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (isImage && !primaryMedia.isNullOrEmpty()) { if (isImage && !primaryMedia.isNullOrEmpty()) {
// CHANGED: the video-aware loader shows a real FRAME of the
// video as the tile background (works for images too).
AsyncImage( AsyncImage(
model = primaryMedia, model = primaryMedia,
contentDescription = "Post Image", imageLoader = videoAwareImageLoader,
contentDescription = if (isVideo) "Post Video" else "Post Image",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@ -493,6 +585,18 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
) )
) )
// NEW: small play icon over the video frame so users know it's a video
if (isVideo) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = "Video",
tint = Color.White.copy(alpha = 0.85f),
modifier = Modifier
.align(Alignment.Center)
.size(32.dp)
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
@ -516,6 +620,8 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
// NEW: image tiles now show like + comment counts too
LikeCommentRow(post)
} }
} else { } else {
Column( Column(
@ -579,20 +685,37 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
) )
} }
// CHANGED: text tiles now show like AND comment counts
LikeCommentRow(post)
}
}
// NEW: stacked-pages badge — shown when the post has MORE THAN ONE
// media item (like Instagram's multi-photo indicator)
if (mediaCount > 1) {
Surface(
color = Color.Black.copy(alpha = 0.55f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp) horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Favorite, imageVector = Icons.Default.Collections,
contentDescription = null, contentDescription = "Multiple media",
tint = Color.White.copy(alpha = 0.8f), tint = Color.White,
modifier = Modifier.size(9.dp) modifier = Modifier.size(11.dp)
) )
Text( Text(
text = "${post.like_count}", text = "$mediaCount",
color = Color.White.copy(alpha = 0.8f), color = Color.White,
fontSize = 8.sp fontSize = 9.sp,
fontWeight = FontWeight.Bold
) )
} }
} }

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

@ -15,6 +15,7 @@ import retrofit2.http.Multipart
import retrofit2.http.Part import retrofit2.http.Part
import com.example.dattingapp.homes.profile.UserResponse import com.example.dattingapp.homes.profile.UserResponse
import com.example.dattingapp.homes.profile.LikedUserResponse import com.example.dattingapp.homes.profile.LikedUserResponse
import com.example.dattingapp.homes.profile.MatchListItem
import retrofit2.http.DELETE import retrofit2.http.DELETE
interface HomeApiService { interface HomeApiService {
@ -78,6 +79,13 @@ interface HomeApiService {
suspend fun getLikedUsers( suspend fun getLikedUsers(
): Response<List<LikedUserResponse>> ): Response<List<LikedUserResponse>>
@GET("api/v1/matches")
suspend fun getMatchList(
@Query("limit") limit: Int = 20,
@Query("offset") offset: Int = 0
): Response<List<MatchListItem>>
@GET("/api/v1/matches") @GET("/api/v1/matches")
suspend fun getMatches( suspend fun getMatches(
@Query("limit") limit: Int = 20, @Query("limit") limit: Int = 20,
@ -128,4 +136,31 @@ interface HomeApiService {
@Path("post_id") postId: Int @Path("post_id") postId: Int
): Response<DeletePostResponse> ): Response<DeletePostResponse>
@GET("api/v1/search")
suspend fun searchUsers(
@Query("q") q: String? = null,
@Query("city") city: String? = null,
@Query("country") country: String? = null,
@Query("latitude") latitude: Double? = null,
@Query("longitude") longitude: Double? = null,
@Query("max_distance_km") maxDistanceKm: Double? = null,
@Query("limit") limit: Int = 20
): Response<List<MeetUserProfile>>
@GET("api/v1/explore/search") // <-- change to your real post-search path
suspend fun searchPosts(
@Query("q") q: String? = null,
@Query("city") city: String? = null,
@Query("country") country: String? = null,
@Query("latitude") latitude: Double? = null,
@Query("longitude") longitude: Double? = null,
@Query("max_distance_km") maxDistanceKm: Double? = null,
@Query("limit") limit: Int = 20
): Response<List<ShowPostResponse>>
@DELETE("api/v1/explore/posts/{post_id}/like")
suspend fun unlikePost(
@Path("post_id") postId: Int
): Response<LikePostResponse>
} }

45
app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt

@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
private const val MEDIA_BASE_URL = "https://melove.youthome.com.cn" private const val MEDIA_BASE_URL = "https://melove.youthome.com.cn"
@ -88,18 +90,27 @@ data class PostAllLikerResponse(
@SerializedName("full_name") val fullName: String @SerializedName("full_name") val fullName: String
) )
data class MediaItem(
@SerializedName("id")
val id: Int,
@SerializedName("media_type")
val mediaType: String?,
@SerializedName("url") data class MediaItem(
val id: Long,
@SerializedName("media_type") val mediaType: String?, // ADD annotation
@SerializedName("mime_type") val mimeType: String?, // ADD annotation
@SerializedName("size_bytes") val sizeBytes: Long?, // ADD annotation
val position: Int?,
val url: String? val url: String?
) { ) {
// ADD everything below ↓
val fullMediaUrl: String? val fullMediaUrl: String?
get() = resolveMediaUrl(url) get() {
val path = url?.trim()?.takeIf { it.isNotBlank() } ?: return null
val resolved = when {
path.startsWith("https://", ignoreCase = true) -> path
path.startsWith("http://", ignoreCase = true) -> path
path.startsWith("//") -> "https:$path"
else -> "https://melove.youthome.com.cn/${path.trimStart('/')}"
}
return resolved.replace("%2F", "/")
}
} }
//feeling data for create post //feeling data for create post
@ -322,3 +333,21 @@ fun rememberPostInteraction(postId: Int): PostInteraction? {
} }
return flow.collectAsState(initial = PostInteractionStore.state.value[postId]).value return flow.collectAsState(initial = PostInteractionStore.state.value[postId]).value
} }
data class SearchUserResponse(
@SerializedName("id") val id: Int,
@SerializedName("full_name") val fullName: String?,
@SerializedName("city") val city: String?,
@SerializedName("country") val country: String?,
@SerializedName("profile_image_url") val profileImageUrl: String?,
@SerializedName("compatibility_percentage") val compatibilityPercentage: Int?,
@SerializedName("distance_km") val distanceKm: Double?
)
// Response data model matching your Apifox screenshot
data class UnLikePostResponse(
val message: String,
val like_count: Int,
val liked_by_me: Boolean,
val liked_by_users: List<Any> = emptyList()
)

1
app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt

@ -9,7 +9,6 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object AppLog { object AppLog {
const val TAG = "PostFlow" // ← the ONLY tag you filter in Logcat const val TAG = "PostFlow" // ← the ONLY tag you filter in Logcat
} }

234
app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt

@ -25,6 +25,8 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.lang.Exception import java.lang.Exception
import com.example.dattingapp.homes.profile.LikedUserResponse import com.example.dattingapp.homes.profile.LikedUserResponse
import com.example.dattingapp.homes.profile.MatchListItem
import kotlinx.coroutines.Job
import org.json.JSONObject import org.json.JSONObject
sealed class MeetUiState { sealed class MeetUiState {
@ -798,19 +800,20 @@ class ShowPostViewModel : ViewModel() {
} }
// NEW: no longer depends on ShowPostUiState.Success, so it works from the feed too // NEW: no longer depends on ShowPostUiState.Success, so it works from the feed too
fun toggleLikeLocallyAndServer(context: Context, postId: Int) { fun toggleLikeLocallyAndServer(context: Context, postId: Int, currentlyLiked: Boolean) {
val appContext = context.applicationContext val appContext = context.applicationContext
// 1. Optimistic update: feed AND details screens change instantly // 1. Optimistic update
PostInteractionStore.markLikePending(postId) PostInteractionStore.markLikePending(postId)
PostInteractionStore.toggleLike(postId) PostInteractionStore.toggleLike(postId)
// 2. Server request // 2. Server request: DELETE to unlike, POST to like
viewModelScope.launch { viewModelScope.launch {
try { try {
val response = HomeNetworkModule val api = HomeNetworkModule.getApiService(appContext)
.getApiService(appContext) val response =
.toggleLikePost(postId) if (currentlyLiked) api.unlikePost(postId)
else api.toggleLikePost(postId)
if (response.isSuccessful && response.body() != null) { if (response.isSuccessful && response.body() != null) {
val serverData = response.body()!! val serverData = response.body()!!
@ -818,7 +821,6 @@ class ShowPostViewModel : ViewModel() {
postId, serverData.likedByMe, serverData.likeCount postId, serverData.likedByMe, serverData.likeCount
) )
// keep this screen's own post state in sync too (if loaded)
val s = _showPostUiState.value val s = _showPostUiState.value
if (s is ShowPostUiState.Success && s.post.id == postId) { if (s is ShowPostUiState.Success && s.post.id == postId) {
_showPostUiState.value = ShowPostUiState.Success( _showPostUiState.value = ShowPostUiState.Success(
@ -829,11 +831,11 @@ class ShowPostViewModel : ViewModel() {
) )
} }
} else { } else {
Log.e("LikeError", "Failed to sync like with server, rolling back") Log.e("LikeError", "Like/unlike failed (${response.code()}), rolling back")
PostInteractionStore.toggleLike(postId) PostInteractionStore.toggleLike(postId)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("LikeViewModel", "Exception while toggling like, rolling back", e) Log.e("LikeViewModel", "Exception while liking/unliking, rolling back", e)
PostInteractionStore.toggleLike(postId) PostInteractionStore.toggleLike(postId)
} finally { } finally {
PostInteractionStore.clearLikePending(postId) PostInteractionStore.clearLikePending(postId)
@ -841,6 +843,8 @@ class ShowPostViewModel : ViewModel() {
} }
} }
fun fetchPostLikers(context: Context, postId: Int) { fun fetchPostLikers(context: Context, postId: Int) {
val appContext = context.applicationContext val appContext = context.applicationContext
viewModelScope.launch { viewModelScope.launch {
@ -949,6 +953,8 @@ class ShowPostViewModel : ViewModel() {
} }
} }
} }
} }
sealed interface FeedUiState { sealed interface FeedUiState {
@ -1015,3 +1021,213 @@ class ExploreFeedViewModel : ViewModel() {
} }
} }
} }
sealed interface SearchUiState {
object Idle : SearchUiState
object Loading : SearchUiState
data class Success(val results: List<MeetUserProfile>) : SearchUiState
data class Error(val message: String) : SearchUiState
}
class SearchViewModel : ViewModel() {
private val _state = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
val state: StateFlow<SearchUiState> = _state.asStateFlow()
private var job: Job? = null
fun clearSearch() {
job?.cancel()
_state.value = SearchUiState.Idle
}
fun search(
context: Context,
q: String?, city: String?, country: String?,
latitude: Double?, longitude: Double?, maxDistanceKm: Double?
) {
val appContext = context.applicationContext
val query = q?.trim()?.ifEmpty { null }
val cityArg = city?.trim()?.ifEmpty { null }
val countryArg = country?.trim()?.ifEmpty { null }
val nearby = latitude != null && longitude != null
if (query == null && cityArg == null && countryArg == null && !nearby) {
_state.value = SearchUiState.Error("Enter a keyword, city, country or use your location.")
return
}
job?.cancel() // a new search replaces the old one
job = viewModelScope.launch {
_state.value = SearchUiState.Loading
try {
val response = HomeNetworkModule.getApiService(appContext).searchUsers(
q = query,
city = cityArg,
country = countryArg,
latitude = if (nearby) latitude else null,
longitude = if (nearby) longitude else null,
maxDistanceKm = if (nearby) maxDistanceKm else null,
limit = 20
)
if (response.isSuccessful) {
val list = response.body().orEmpty()
_state.value =
if (list.isEmpty()) SearchUiState.Error("No results found.")
else SearchUiState.Success(list)
} else {
val err = response.errorBody()?.string()
Log.e("SearchApi", "HTTP ${response.code()}: $err url=${response.raw().request.url}")
_state.value = SearchUiState.Error(
when (response.code()) {
401 -> "Authentication failed. Please log in again."
422 -> "Invalid search parameters."
else -> "Search failed: HTTP ${response.code()}."
}
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e("SearchApi", "Search failed", e)
_state.value = SearchUiState.Error("Request failed: ${e.javaClass.simpleName}")
}
}
}
}
sealed interface PostSearchUiState {
object Idle : PostSearchUiState
object Loading : PostSearchUiState
data class Success(val posts: List<ShowPostResponse>) : PostSearchUiState
data class Error(val message: String) : PostSearchUiState
}
class PostSearchViewModel : ViewModel() {
private val _state = MutableStateFlow<PostSearchUiState>(PostSearchUiState.Idle)
val state: StateFlow<PostSearchUiState> = _state.asStateFlow()
private var job: kotlinx.coroutines.Job? = null
fun search(
context: Context,
q: String?, city: String?, country: String?,
latitude: Double?, longitude: Double?, maxDistanceKm: Double?
) {
val appContext = context.applicationContext
val query = q?.trim()?.ifEmpty { null }
val cityArg = city?.trim()?.ifEmpty { null }
val countryArg = country?.trim()?.ifEmpty { null }
val nearby = latitude != null && longitude != null
if (query == null && cityArg == null && countryArg == null && !nearby) {
_state.value = PostSearchUiState.Error("Enter a keyword, city, country or use your location.")
return
}
job?.cancel()
job = viewModelScope.launch {
_state.value = PostSearchUiState.Loading
try {
val response = HomeNetworkModule.getApiService(appContext).searchPosts(
q = query,
city = cityArg,
country = countryArg,
latitude = if (nearby) latitude else null,
longitude = if (nearby) longitude else null,
maxDistanceKm = if (nearby) maxDistanceKm else null,
limit = 20
)
if (response.isSuccessful) {
val list = response.body().orEmpty()
if (list.isEmpty()) {
_state.value = PostSearchUiState.Error("No posts found.")
} else {
// keep like/comment counts in sync with the shared store
list.forEach {
PostInteractionStore.setFromServer(
it.id, it.liked_by_me, it.like_count, it.comment_count
)
}
_state.value = PostSearchUiState.Success(list)
}
} else {
val err = response.errorBody()?.string()
Log.e("PostSearch", "HTTP ${response.code()}: $err url=${response.raw().request.url}")
_state.value = PostSearchUiState.Error(
when (response.code()) {
401 -> "Authentication failed. Please log in again."
404 -> "Search endpoint not found."
422 -> "Invalid search parameters."
else -> "Search failed: HTTP ${response.code()}."
}
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e("PostSearch", "Search failed", e)
_state.value = PostSearchUiState.Error("Request failed: ${e.javaClass.simpleName}")
}
}
}
fun clearSearch() {
job?.cancel()
_state.value = PostSearchUiState.Idle
}
}
sealed interface MatchesUiState {
object Idle : MatchesUiState
object Loading : MatchesUiState
data class Success(val matches: List<MatchListItem>) : MatchesUiState
data class Error(val message: String) : MatchesUiState
}
class MatchesViewModel : ViewModel() {
private val _state = MutableStateFlow<MatchesUiState>(MatchesUiState.Idle)
val state: StateFlow<MatchesUiState> = _state.asStateFlow()
fun loadMatches(context: Context) {
if (_state.value is MatchesUiState.Loading) return
val appContext = context.applicationContext
viewModelScope.launch {
_state.value = MatchesUiState.Loading
if (TokenManager.getInstance(appContext).getAccessToken().isNullOrBlank()) {
_state.value = MatchesUiState.Error("No access token found. Please log in again.")
return@launch
}
try {
val response = HomeNetworkModule.getApiService(appContext).getMatchList(20, 0)
Log.d("MatchesApi", "Response code: ${response.code()}")
if (response.isSuccessful) {
// drop entries with no user so the grid never sees a null user
val list = response.body().orEmpty().filter { it.user != null }
_state.value = MatchesUiState.Success(list)
} else {
val err = response.errorBody()?.string()
Log.e("MatchesApi", "HTTP ${response.code()}: $err")
_state.value = MatchesUiState.Error(
when (response.code()) {
401 -> "Authentication failed. Please log in again."
403 -> "The server refused access to matches."
404 -> "Matches endpoint was not found."
else -> "Request failed: HTTP ${response.code()}."
}
)
}
} catch (e: HttpException) {
_state.value = MatchesUiState.Error("Request failed: HTTP ${e.code()}.")
} catch (e: Exception) {
Log.e("MatchesApi", "Unexpected failure", e)
_state.value = MatchesUiState.Error("Request failed: ${e.javaClass.simpleName}")
}
}
}
}

26
app/src/main/java/com/example/dattingapp/homes/profile/UserResponseData.kt

@ -105,3 +105,29 @@ data class LikedUserResponse(
val compatibility_percentage: Int, val compatibility_percentage: Int,
val media: List<Media> // Adjust if media contains custom objects val media: List<Media> // Adjust if media contains custom objects
) )
// One item of GET /api/v1/matches
data class MatchListItem(
val id: Int,
val matched_at: String? = null,
val user: MatchedUser? = null
)
data class MatchedUser(
val id: Int,
val full_name: String? = null,
val gender: String? = null,
val birth_date: String? = null,
val city: String? = null,
val county: String? = null,
val country: String? = null,
val relationship_goal: String? = null,
val bio: String? = null,
val profile_image_url: String? = null,
val video_url: String? = null,
val distance_km: Double? = null,
val compatibility_percentage: Int? = null
)

1
app/src/main/res/values/strings.xml

@ -91,6 +91,7 @@
<string name="title_activity_upload_intro_video">UploadIntroVideoActivity</string> <string name="title_activity_upload_intro_video">UploadIntroVideoActivity</string>
<string name="title_activity_like_and_match">LikeANdMatchActivity</string> <string name="title_activity_like_and_match">LikeANdMatchActivity</string>
<string name="title_activity_show_posting">ShowPostingActivity</string> <string name="title_activity_show_posting">ShowPostingActivity</string>
<string name="title_activity_matches">MatchesActivity</string>
</resources> </resources>
Loading…
Cancel
Save