diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index afc894b..977ce7e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -90,6 +90,7 @@ dependencies {
// For Hilt ViewModel support in Jetpack Compose (if using Compose)
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
+ implementation("io.coil-kt:coil-video:2.6.0")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 85d585c..24f686d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -139,6 +139,12 @@
android:label="@string/title_activity_show_posting"
android:theme="@style/Theme.DattingAPP"
android:windowSoftInputMode="adjustResize" />
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/dattingapp/FriendProfile.kt b/app/src/main/java/com/example/dattingapp/FriendProfile.kt
index bb8d4e0..1460bae 100644
--- a/app/src/main/java/com/example/dattingapp/FriendProfile.kt
+++ b/app/src/main/java/com/example/dattingapp/FriendProfile.kt
@@ -84,9 +84,6 @@ class FriendProfile : ComponentActivity() {
}
}
-
-
-
when (val state = profileUiState) {
is FriendProfileUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -125,40 +122,53 @@ fun FriendProfileScreen(
onStarClick: () -> Unit = {},
onCancelClick: () -> Unit = {}
) {
-
val likedViewModel: LikedViewModel = viewModel()
+ val matchesViewModel: MatchesViewModel = viewModel()
- // ✅ collect each state separately
- val likesState by likedViewModel.likesUiState.collectAsState() // who liked me
- val likedState by likedViewModel.likedUiState.collectAsState() // I liked
- val matchesState by likedViewModel.matchesUiState.collectAsState() // matches
+ // Collect response states
+ val likesState by likedViewModel.likesUiState.collectAsState() // Users who liked me
+ val likedState by likedViewModel.likedUiState.collectAsState() // Users I liked
+ val matchesState by matchesViewModel.state.collectAsState() // Match list from server
- // ✅ load all three lists once
+ // Fetch lists once
LaunchedEffect(Unit) {
likedViewModel.likedResponse(context, "Likes")
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)
?.LikeData?.map { it.id }?.toSet() ?: emptySet()
val likedMeIds = (likedState as? LikedUiState.Success)
?.LikeData?.map { it.id }?.toSet() ?: emptySet()
- val matchIds = (matchesState as? LikedUiState.Success)
- ?.LikeData?.map { it.id }?.toSet() ?: emptySet()
+ val matchIds = (matchesState as? MatchesUiState.Success)
+ ?.matches?.map { it.user?.id }?.toSet() ?: emptySet()
- // ✅ the three booleans for the button row
- val isMatch = friendProfileData.id in matchIds
+ // Base relationship status flags
val heLikedMe = friendProfileData.id in likedMeIds
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) }
-// 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 age = remember(friendProfileData.birth_date) {
@@ -265,7 +275,7 @@ fun FriendProfileScreen(
modifier = Modifier.size(10.dp)
)
Text(
- text = "${friendProfileData.county?: 0}% Match",
+ text = "${friendProfileData.county ?: 0}% Match",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
@@ -310,7 +320,9 @@ fun FriendProfileScreen(
)
}
- //action button
+ // =========================================================================
+ // ACTION BUTTON ROW WITH EVALUATED MATCH STATES
+ // =========================================================================
Row(
modifier = Modifier
.fillMaxWidth()
@@ -318,21 +330,20 @@ fun FriendProfileScreen(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
- // Message — card style, no background color
- // Message — enabled only when matched, grayed out otherwise
+ // 💬 Message Button — Enabled ONLY when matched
Card(
onClick = onMessageClick,
- enabled = isMatch, // ✅ disabled when not matched
+ enabled = isMessageClickable,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
- containerColor = if (isMatch) Color.White else Color(0xFFF0F0F0),
+ containerColor = if (isMessageClickable) Color.White else Color(0xFFF0F0F0),
disabledContainerColor = Color(0xFFF0F0F0)
),
elevation = CardDefaults.cardElevation(
- defaultElevation = if (isMatch) 4.dp else 0.dp
+ defaultElevation = if (isMessageClickable) 4.dp else 0.dp
)
) {
Row(
@@ -343,7 +354,7 @@ fun FriendProfileScreen(
Icon(
imageVector = Icons.Default.ChatBubble,
contentDescription = "Message",
- tint = if (isMatch) textDark else Color(0xFFBDBDBD), // gray when disabled
+ tint = if (isMessageClickable) textDark else Color(0xFFBDBDBD),
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
@@ -351,75 +362,86 @@ fun FriendProfileScreen(
text = "Message",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
- color = if (isMatch) textDark else Color(0xFFBDBDBD) // gray when disabled
+ color = if (isMessageClickable) textDark else Color(0xFFBDBDBD)
)
}
}
-
- // ❤️ Like — red ICON when the user liked ME, normal otherwise
- // hidden when matched
-
- // ❤️ Like — RED background if user liked me, DISABLED if I liked them
- Card(
- onClick = {
- onLikeClick()
- handleLikeAndDislikeAction(context, "right", friendProfileData, swipeViewModel)
- localLiked = true // ✅ disable after first click
- },
- enabled = !likeDisabled,
- modifier = Modifier.size(48.dp),
- shape = RoundedCornerShape(10.dp),
- colors = CardDefaults.cardColors(
- containerColor = if (heLikedMe) Color(0xFFE53935) else Color.White,
- disabledContainerColor = if (heLikedMe) Color(0xFFE53935) else Color(0xFFF0F0F0)
- ),
- elevation = CardDefaults.cardElevation(
- 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)
+ // ❤️ Like & ⭐ Super Like Buttons — Hidden when matched
+ if (showLikeAndSuperLike) {
+ // ❤️ Like Button
+ Card(
+ onClick = {
+ onLikeClick()
+ handleLikeAndDislikeAction(context, "like", friendProfileData, swipeViewModel)
+ localLiked = true
+ matchesViewModel.loadMatches(context)
+ },
+ enabled = !likeDisabled,
+ modifier = Modifier.size(48.dp),
+ shape = RoundedCornerShape(10.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (heLikedMe) Color(0xFFE53935) else Color.White,
+ disabledContainerColor = if (heLikedMe) Color(0xFFE53935) else Color(0xFFF0F0F0)
+ ),
+ elevation = CardDefaults.cardElevation(
+ 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
+ likeDisabled -> Color(0xFFBDBDBD)
+ else -> textDark
+ },
+ modifier = Modifier.size(20.dp)
+ )
+ }
}
- }
- // Super Like — card style
- Card(
- onClick = {
- onStarClick()
- handleLikeAndDislikeAction(context, "right", friendProfileData, swipeViewModel)
- },
- modifier = Modifier.size(48.dp),
- shape = RoundedCornerShape(10.dp),
- colors = CardDefaults.cardColors(containerColor = Color.White),
- elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
- ) {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Icon(
- imageVector = Icons.Default.Star,
- contentDescription = "Favorite",
- tint = textDark,
- modifier = Modifier.size(20.dp)
+ // ⭐ Super Like (Star) Button
+ Card(
+ onClick = {
+ onStarClick()
+ handleLikeAndDislikeAction(context, "like", friendProfileData, swipeViewModel)
+ localLiked = true
+ matchesViewModel.loadMatches(context)
+ },
+ enabled = !likeDisabled,
+ modifier = Modifier.size(48.dp),
+ shape = RoundedCornerShape(10.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = Color.White,
+ disabledContainerColor = Color(0xFFF0F0F0)
+ ),
+ elevation = CardDefaults.cardElevation(
+ 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 — clicking it re-enables the Like button
+ // ❌ Cancel / Dislike Button — Resets everything back to initial state
Card(
onClick = {
onCancelClick()
- handleLikeAndDislikeAction(context, "left", friendProfileData, swipeViewModel)
- localLiked = false // ✅ Like button works again
+ handleLikeAndDislikeAction(context, "pass", friendProfileData, swipeViewModel)
+ localLiked = false
+
+ likedViewModel.likedResponse(context, "Likes")
+ likedViewModel.likedResponse(context, "Liked")
+ matchesViewModel.loadMatches(context)
},
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(10.dp),
@@ -436,9 +458,10 @@ fun FriendProfileScreen(
}
}
}
+
Spacer(modifier = Modifier.height(16.dp))
- // User Posts Grid Section below Action Row
+ // User Posts Section
Text(
text = "Posts",
fontSize = 18.sp,
@@ -450,16 +473,12 @@ fun FriendProfileScreen(
Spacer(modifier = Modifier.height(16.dp))
StaggeredFriendPostsGrid(posts = friendProfileData.posts)
-
}
}
}
-
-
@Composable
fun StaggeredFriendPostsGrid(posts: List) {
- // Distribute posts into two columns
val columns = remember(posts) {
val col1 = mutableListOf()
val col2 = mutableListOf()
@@ -475,24 +494,17 @@ fun StaggeredFriendPostsGrid(posts: List) {
}
Row(
- modifier = Modifier
- .fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(3.dp)
) {
- // Column 1
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
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
MasonryPostItem(post = post, aspectRatio = aspectRatio)
}
}
- // Column 2
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
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
MasonryPostItem(post = post, aspectRatio = aspectRatio)
}
@@ -502,7 +514,7 @@ fun StaggeredFriendPostsGrid(posts: List) {
@Composable
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 isImage = post.post_type.equals("image", ignoreCase = true) || !primaryMedia.isNullOrEmpty()
@@ -514,20 +526,14 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
.aspectRatio(aspectRatio)
.clickable {
val intent = Intent(context, ShowPostingActivity::class.java).apply {
- val postID =post.id
- Log.d("Post_ID","$postID")
+ val postID = post.id
+ Log.d("Post_ID", "$postID")
putExtra("Post_ID", postID)
}
context.startActivity(intent)
}
) {
- Box(
- modifier = Modifier
- .fillMaxSize()
-// .background(
-// if (isImage) Color.DarkGray else getMasonryGradient(post.post_type)
-// )
- ) {
+ Box(modifier = Modifier.fillMaxSize()) {
if (isImage && !primaryMedia.isNullOrEmpty()) {
AsyncImage(
model = primaryMedia,
@@ -536,7 +542,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
modifier = Modifier.fillMaxSize()
)
- // Dark Gradient Scrim for text readability over images
Box(
modifier = Modifier
.fillMaxSize()
@@ -548,7 +553,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
)
)
- // Image metadata overlay (Feeling or Status snippet)
Column(
modifier = Modifier
.align(Alignment.BottomStart)
@@ -579,7 +583,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
}
}
} else {
- // Text / Status / Vibe Card Design
Column(
modifier = Modifier
.fillMaxSize()
@@ -614,7 +617,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
- // Feeling tag if available
if (!post.feeling.isNullOrBlank()) {
Surface(
shape = RoundedCornerShape(6.dp),
@@ -631,7 +633,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
}
}
- // Main Content / Status Line
Text(
text = post.content ?: "",
color = Color.White,
@@ -643,7 +644,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
)
}
- // Footer Likes
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
@@ -666,7 +666,6 @@ fun MasonryPostItem(post: FriendPost, aspectRatio: Float) {
}
}
-
private fun handleLikeAndDislikeAction(
context: Context,
direction: String,
@@ -687,233 +686,4 @@ private fun calculateAge(birthDateStr: String?): Int? {
} catch (e: Exception) {
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)
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/dattingapp/LikeANdMatchActivity.kt b/app/src/main/java/com/example/dattingapp/LikeANdMatchActivity.kt
index 1e69f69..861b429 100644
--- a/app/src/main/java/com/example/dattingapp/LikeANdMatchActivity.kt
+++ b/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.shape.RoundedCornerShape
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.Favorite
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.Period
import java.time.format.DateTimeFormatter
-import kotlin.jvm.java
class LikeANdMatchActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -58,14 +56,12 @@ class LikeANdMatchActivity : ComponentActivity() {
val context = LocalContext.current
val type = intent.getStringExtra("TYPE") ?: "Likes"
- // Dynamically collect the correct StateFlow based on the 'type'
val uiState by when (type) {
"Likes" -> likedViewModel.likesUiState.collectAsState()
"Liked" -> likedViewModel.likedUiState.collectAsState()
else -> likedViewModel.matchesUiState.collectAsState()
}
- // Trigger network call once when screen loads
LaunchedEffect(Unit) {
likedViewModel.likedResponse(context, type)
}
@@ -76,7 +72,6 @@ class LikeANdMatchActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
TopAppBar(
title = {
- // Display dynamic title based on the page type
Text(
text = when (type) {
"Likes" -> "My Likes"
@@ -142,10 +137,13 @@ class LikeANdMatchActivity : ComponentActivity() {
}
}
}
-// 1. Define ProfileCard FIRST so ProfileGrid can reference it without issue
+
@Composable
-fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Modifier = Modifier,
- onNameClick: () -> Unit={},
+fun ProfileCard(
+ context: Context,
+ likedProfile: LikedUserResponse,
+ modifier: Modifier = Modifier,
+ onNameClick: () -> Unit = {},
) {
Card(
shape = RoundedCornerShape(16.dp),
@@ -153,33 +151,29 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
modifier = modifier.fillMaxWidth()
) {
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(12.dp))
.clickable {
-// Create an intent to launch your target activity (e.g., FriendProfileActivity)
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)
-
- 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(
model = likedProfile.profile_image_url?.takeIf { it.isNotBlank() }
- ?: likedProfile.media.firstOrNull(),
+ ?: likedProfile.media?.firstOrNull(),
contentDescription = likedProfile.full_name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
- // Gradient Overlay
Box(
modifier = Modifier
.fillMaxSize()
@@ -191,7 +185,6 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
)
)
- // Match Badge
Surface(
shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.9f),
@@ -211,7 +204,7 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
modifier = Modifier.size(12.dp)
)
Text(
- text = "${likedProfile.compatibility_percentage}%",
+ text = "${likedProfile.compatibility_percentage ?: 0}%",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF2D3436)
@@ -219,23 +212,21 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
}
}
- // Name, Age & Distance
Column(
modifier = Modifier
.align(Alignment.BottomStart)
- // Makes just this text section clickable
.padding(8.dp)
- ){
- val displayName = likedProfile.full_name?.ifBlank { "User" }
+ ) {
+ val displayName = likedProfile.full_name?.ifBlank { "User" } ?: "User"
val age = remember(likedProfile.birth_date) {
calculateAge(likedProfile.birth_date)
}
val nameAndAge = if (age != null) "$displayName, $age" else displayName
- Log.d("user","$likedProfile.id")
+ Log.d("user", "${likedProfile.id}")
Text(
- text = nameAndAge?:"",
+ text = nameAndAge,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
@@ -262,7 +253,6 @@ fun ProfileCard(context: Context, likedProfile: LikedUserResponse, modifier: Mod
}
}
-// 2. Define ProfileGrid AFTER ProfileCard
@Composable
fun ProfileGrid(
context: Context,
@@ -274,7 +264,7 @@ fun ProfileGrid(
) {
if (likedResponse.isEmpty() && !isLoading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("No liked profiles found.", color = Color.Gray)
+ Text("No profiles found.", color = Color.Gray)
}
return
}
@@ -287,12 +277,11 @@ fun ProfileGrid(
modifier = modifier.fillMaxSize()
) {
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? {
if (birthDateStr.isNullOrBlank()) return null
return try {
@@ -329,7 +318,7 @@ fun ProfileGridPreview() {
DattingAPPTheme {
ProfileGrid(
- context=context,
+ context = context,
likedResponse = sampleLikedUsers,
isLoading = false,
errorMessage = null
diff --git a/app/src/main/java/com/example/dattingapp/MatchesActivity.kt b/app/src/main/java/com/example/dattingapp/MatchesActivity.kt
new file mode 100644
index 0000000..a97ce01
--- /dev/null
+++ b/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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt b/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt
index 1bd5d5e..94534f9 100644
--- a/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt
+++ b/app/src/main/java/com/example/dattingapp/ShowPostingActivity.kt
@@ -10,7 +10,10 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
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.shape.CircleShape
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.FavoriteBorder
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.outlined.ChatBubbleOutline
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.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
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 com.example.dattingapp.dataclass.Author
import com.example.dattingapp.dataclass.CommentResponse
import com.example.dattingapp.dataclass.Location
+import com.example.dattingapp.dataclass.MediaItem
import com.example.dattingapp.dataclass.ShowPostResponse
import com.example.dattingapp.homes.networkandapi.PostAllLikerResponse
import com.example.dattingapp.homes.networkandapi.ShowPostUiState
@@ -57,6 +68,22 @@ import com.example.dattingapp.ui.theme.TextPostTextColor
val AccentPink = Color(0xFFD81B60)
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() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -261,10 +288,11 @@ fun PostCard(
.padding(start = 10.dp, end = 10.dp)
.height(360.dp),
) {
- AsyncImage(
- model = post.media.first().url,
- contentDescription = "Post Media Image",
- contentScale = ContentScale.Crop,
+ // FIX: was AsyncImage(model = post.media.first().url) — showed ONLY
+ // the first media item, always as an image, with the raw URL.
+ // Now: swipeable pager over ALL media, images AND videos.
+ PostMediaPager(
+ media = post.media,
modifier = Modifier.fillMaxSize()
)
}
@@ -304,9 +332,9 @@ fun PostCard(
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
- IconButton(onClick = {
- showPostViewModel.toggleLikeLocallyAndServer(context, post.id)
- }) {
+ IconButton(
+ onClick = { showPostViewModel.toggleLikeLocallyAndServer(context, post.id, likedByMe) }
+ ) {
Icon(
imageVector = if (likedByMe) Icons.Filled.Favorite else Icons.Default.FavoriteBorder,
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, 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
fun CommentSection(
comments: List,
@@ -615,13 +765,21 @@ fun ShowPostScreenPreview() {
comment_count = 2,
liked_by_me = true,
media = listOf(
- com.example.dattingapp.dataclass.MediaItem(
+ MediaItem(
id = 5,
mediaType = "image",
mimeType = "image/png",
sizeBytes = 199619,
position = 0,
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"
diff --git a/app/src/main/java/com/example/dattingapp/dataclass/DataClass.kt b/app/src/main/java/com/example/dattingapp/dataclass/DataClass.kt
index 18fda77..1560330 100644
--- a/app/src/main/java/com/example/dattingapp/dataclass/DataClass.kt
+++ b/app/src/main/java/com/example/dattingapp/dataclass/DataClass.kt
@@ -1,5 +1,6 @@
package com.example.dattingapp.dataclass
+import com.google.gson.annotations.SerializedName
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -39,15 +40,28 @@ data class Author(
@SerialName("compatibility_percentage") val compatibilityPercentage: Int? = 0
)
-@Serializable
+
data class MediaItem(
val id: Long,
- @SerialName("media_type") val mediaType: String, // "image" or "video"
- @SerialName("mime_type") val mimeType: String?,
- @SerialName("size_bytes") val sizeBytes: 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?
+ 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
diff --git a/app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt b/app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt
index bf7c27f..cf5375e 100644
--- a/app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt
+++ b/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.ui.PlayerView
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.CreatePostViewModel
import com.example.dattingapp.homes.networkandapi.PostMediaViewModel
@@ -84,11 +86,18 @@ fun CreateVibePostScreen(
val maxChar = 500
val selectedMediaList = remember { mutableStateListOf() }
- val post_type = remember(selectedMediaList) {
- when {
- selectedMediaList.any { it is MediaContentItem.Video } -> "video"
- selectedMediaList.any { it is MediaContentItem.Photo } -> "image"
- else -> "text"
+
+ // FIX: was remember(selectedMediaList) { ... } — remember() does NOT observe
+ // snapshot state, and the list instance (the key) never changes, so post_type
+ // was computed once ("text") and every photo/video post was sent as "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
Log.d("CreatePostDebug", "Post created with ID: $postId")
- val errors = mutableListOf()
- selectedMediaList.toList().forEachIndexed { index, item ->
- val err = postMediaViewModel.uploadMedia(context, postId, index, item.uri)
- if (err != null) errors.add("File ${index + 1}: $err")
- }
+ // Backend only accepts ONE file per request
+ // (POST /explore/posts/{id}/media), so upload each file
+ // separately — but IN PARALLEL, not one after another.
+ 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()) {
Toast.makeText(context, errors.first(), Toast.LENGTH_LONG).show()
}
@@ -290,11 +307,10 @@ fun CreateVibePostScreen(
Button(
enabled = !isPosting, // blocks double taps
onClick = {
- if (textState.isBlank() && selectedMediaList.isEmpty()) {
- Toast.makeText(context, "Add text or at least one photo/video", Toast.LENGTH_SHORT).show()
- return@Button
- }
- // ... build request and call createPost()
+ if (textState.isBlank() && selectedMediaList.isEmpty()) {
+ Toast.makeText(context, "Add text or at least one photo/video", Toast.LENGTH_SHORT).show()
+ return@Button
+ }
val postLocation = if (showLocationTag && latitude != null && longitude != null) {
Location(
name = "Current Location",
@@ -304,9 +320,9 @@ fun CreateVibePostScreen(
} else null
val createPostRequest = CreatePostRequest(
- content = textState.trim().ifBlank { " " }, // ← the fix: " " instead of ""
+ content = textState.trim().ifBlank { " " },
post_type = post_type,
- content_format = "plain", // keep markdown, it works
+ content_format = "plain",
feeling = selectedFeeling?.let { "${it.name} ${it.emoji}" },
location = postLocation
)
@@ -420,30 +436,40 @@ fun CreateVibePostScreen(
Spacer(modifier = Modifier.height(4.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ // FIX: location row is only shown when a location is actually
+ // attached, and the X now actually removes it (it was a dead icon).
+ if (showLocationTag && latitude != null && longitude != null) {
+ Row(
+ 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(
- 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
+ imageVector = Icons.Default.Close,
+ contentDescription = "Remove location",
+ tint = Color.Gray,
+ modifier = Modifier
+ .size(16.dp)
+ .clickable(enabled = !isPosting) {
+ latitude = null
+ longitude = null
+ showLocationTag = false
+ }
)
}
- 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() }
}
diff --git a/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt b/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt
index d094231..ad6055f 100644
--- a/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt
+++ b/app/src/main/java/com/example/dattingapp/homes/ExploreScreen.kt
@@ -1,11 +1,13 @@
package com.example.dattingapp.homes
import android.content.Intent
+import android.util.Log
import androidx.compose.foundation.BorderStroke
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.res.painterResource
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.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
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.ShowPostingActivity
import com.example.dattingapp.dataclass.Author
import com.example.dattingapp.dataclass.Location
import com.example.dattingapp.dataclass.MediaItem
import com.example.dattingapp.dataclass.ShowPostResponse
-import com.example.dattingapp.homes.networkandapi.PostInteractionStore // NEW
-import com.example.dattingapp.homes.networkandapi.rememberPostInteraction // NEW
+import com.example.dattingapp.homes.networkandapi.PostInteractionStore
+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.TokenManager
import com.example.dattingapp.ui.theme.DattingAPPTheme
@@ -76,40 +86,85 @@ fun VibeCheckHomeScreen(
isLoading: Boolean,
errorMessage: String?,
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) {
feedResponse.forEach {
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(
modifier = Modifier.fillMaxSize(),
topBar = {
- VibeCheckTopAppBar(primaryColor)
+ VibeCheckTopAppBar(
+ primaryColor = primaryColor,
+ onSearchClick = {
+ if (showSearch) searchViewModel.clearSearch() // closing clears the search
+ showSearch = !showSearch
+ }
+ )
}
) { innerPadding ->
- LazyColumn(
+ Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ .padding(horizontal = 4.dp)
) {
- item {
- SearchBarSection()
+ if (showSearch) {
+ 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 ->
- FeaturedEventCard(
- post = post,
- badgeColor = primaryColor
+ when (val s = searchState) {
+ is PostSearchUiState.Loading -> Box(
+ Modifier
+ .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 {
- Spacer(modifier = Modifier.height(16.dp))
+ // Search results: same post cards as the feed
+ 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
private fun VibeCheckTopAppBar(
- primaryColor: Color
+ primaryColor: Color,
+ onSearchClick: () -> Unit
) {
val context = LocalContext.current
val tokenManager = remember { TokenManager.getInstance(context) }
@@ -160,7 +216,7 @@ private fun VibeCheckTopAppBar(
)
}
- IconButton(onClick = { }) {
+ IconButton(onClick = onSearchClick) {
Icon(
imageVector = Icons.Default.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
private fun VibeFilterChipsSection(
selectedColor: Color,
@@ -239,7 +246,7 @@ private fun VibeFilterChipsSection(
) {
items(categories, key = { it.id }) { category ->
Surface(
- shape = RoundedCornerShape(16.dp),
+ shape = RoundedCornerShape(12.dp),
color = if (category.isSelected) selectedColor else unselectedBg,
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
private fun FeaturedEventCard(
post: ShowPostResponse,
badgeColor: Color,
+ category: String? = null, // e.g. "Coffee Crawl Series" — no field in ShowPostResponse yet
showPostViewModel: ShowPostViewModel = viewModel()
) {
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 likedByMe = live?.likedByMe ?: post.liked_by_me
val likeCount = live?.likeCount ?: post.like_count
val commentCount = live?.commentCount ?: post.comment_count
val hasMedia = post.media.isNotEmpty()
- val tags = listOfNotNull(post.feeling, post.location?.name)
- val contentColor = if (hasMedia) Color.White else TextPostTextColor
- val shadowStyle = if (hasMedia) TextStyle(shadow = OverlayShadow) else TextStyle.Default
+ // Hashtags are pulled out of the text; the body shows the text without them.
+ 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(
shape = RoundedCornerShape(16.dp),
- border = if (hasMedia) null else BorderStroke(1.dp, Color(0xFFEDEDED)),
- colors = CardDefaults.cardColors(
- containerColor = if (hasMedia) Color.DarkGray else Color.White
- ),
+ border = BorderStroke(1.dp, Color(0xFFEDEDED)),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier = Modifier
.fillMaxWidth()
- .height(360.dp)
- .clickable {
- val intent = Intent(context, ShowPostingActivity::class.java).apply {
- putExtra("Post_ID", post.id)
- }
- context.startActivity(intent)
- }
+ .clickable { openDetails() }
) {
- Box(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.padding(12.dp)) {
- // 1. Content: swipeable media, or big centered text
- if (hasMedia) {
- PostMediaPager(media = post.media, modifier = Modifier.fillMaxSize())
-
- // 2. Top scrim
- Box(
+ // ---- 1. Header: avatar, name, feeling, time · location ----
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ AsyncImage(
+ model = post.author?.profile_image_url,
+ contentDescription = "Author image",
+ placeholder = painterResource(id = R.drawable.locationpermissionbg),
+ error = painterResource(id = R.drawable.locationpermissionbg),
+ contentScale = ContentScale.Crop,
modifier = Modifier
- .fillMaxWidth()
- .height(90.dp)
- .align(Alignment.TopCenter)
- .background(
- Brush.verticalGradient(
- listOf(Color.Black.copy(alpha = 0.45f), Color.Transparent)
- )
- )
+ .size(40.dp)
+ .clip(CircleShape)
)
+ 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(
modifier = Modifier
.fillMaxWidth()
- .height(180.dp)
- .align(Alignment.BottomCenter)
- .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
+ .height(200.dp)
+ .clip(RoundedCornerShape(12.dp))
) {
- Icon(
- imageVector = when (post.postType) {
- "video" -> Icons.Default.Videocam
- "image" -> Icons.Default.Image
- 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
+ PostMediaPager(
+ media = post.media,
+ onVideoClick = openDetails,
+ modifier = Modifier.fillMaxSize()
)
+ 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
- Column(
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(end = 12.dp, bottom = 16.dp)
- .clip(RoundedCornerShape(24.dp))
- .padding(vertical = 4.dp, horizontal = 2.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- // Like + count
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- IconButton(onClick = {
- showPostViewModel.toggleLikeLocallyAndServer(context, post.id)
- }) {
- Icon(
- imageVector = if (likedByMe) Icons.Default.Favorite else Icons.Outlined.FavoriteBorder,
- contentDescription = "Like",
- tint = if (likedByMe) Color.Red else contentColor,
- modifier = Modifier.size(28.dp)
- )
+ // ---- 4. Hashtags ----
+ Spacer(Modifier.height(10.dp))
+ if (hashtags.isEmpty()) {
+ OptionalText(value = null, placeholder = "No hashtags", fontSize = 12.sp)
+ } else {
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ items(hashtags) { tag ->
+ Surface(color = lightChipBg, shape = RoundedCornerShape(8.dp)) {
+ Text(
+ text = tag,
+ color = badgeColor,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ )
+ }
}
- Text(
- text = "$likeCount",
- color = contentColor,
- fontSize = 12.sp,
- style = shadowStyle
- )
}
+ }
- // Comment + count
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ // ---- 5. Actions: like, comment, share ----
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(
+ onClick = { showPostViewModel.toggleLikeLocallyAndServer(context, post.id, likedByMe) },
+ modifier = Modifier.size(32.dp)
+ ) {
Icon(
- imageVector = Icons.Outlined.ChatBubbleOutline,
- contentDescription = "Comment",
- tint = contentColor,
- modifier = Modifier.size(28.dp)
- )
- Text(
- text = "$commentCount",
- color = contentColor,
- fontSize = 12.sp,
- style = shadowStyle
+ imageVector = if (likedByMe) Icons.Default.Favorite else Icons.Outlined.FavoriteBorder,
+ contentDescription = "Like",
+ tint = if (likedByMe) Color.Red else Color.Gray,
+ modifier = Modifier.size(20.dp)
)
}
- }
+ Text("$likeCount", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
- // 6. Bottom info
- Column(
- modifier = Modifier
- .align(Alignment.BottomStart)
- .padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 72.dp)
- ) {
- Text(
- text = post.author?.full_name ?: "Anonymous",
- color = contentColor,
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold,
- style = shadowStyle
+ Spacer(Modifier.width(14.dp))
+
+ Icon(
+ imageVector = Icons.Outlined.ChatBubbleOutline,
+ contentDescription = "Comment",
+ tint = Color.Gray,
+ modifier = Modifier.size(20.dp)
)
+ Spacer(Modifier.width(4.dp))
+ Text("$commentCount", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
- if (hasMedia && !post.content.isNullOrBlank()) {
- 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
- )
- }
+ Spacer(Modifier.weight(1f))
- if (tags.isNotEmpty()) {
- Spacer(Modifier.height(8.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- tags.forEach { tag ->
- Surface(
- 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)
- )
- }
- }
- }
- }
+ Icon(
+ imageVector = Icons.Outlined.Share,
+ contentDescription = "Share",
+ tint = Color.Gray,
+ modifier = Modifier.size(20.dp)
+ )
}
}
}
}
@Composable
-private fun PostMediaPager(media: List, modifier: Modifier = Modifier) {
+private fun PostMediaPager(
+ media: List,
+ onVideoClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
val pagerState = rememberPagerState(pageCount = { media.size })
Box(modifier) {
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { 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") {
- PostVideoPlayer(
- url = item.url,
- isActive = pagerState.currentPage == page
- )
+ if (resolvedUrl != null) {
+ PostVideoPlayer(
+ url = resolvedUrl,
+ isActive = pagerState.currentPage == page,
+ onClick = onVideoClick
+ )
+ } else {
+ Log.e("PostMediaPager", "Video item ${item.id} has no usable URL: ${item.url}")
+ }
} else {
AsyncImage(
- model = item.url,
+ model = resolvedUrl,
contentDescription = "Post media ${page + 1}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
@@ -513,7 +592,7 @@ private fun PostMediaPager(media: List, modifier: Modifier = Modifier
}
@Composable
-private fun PostVideoPlayer(url: String, isActive: Boolean) {
+private fun PostVideoPlayer(url: String, isActive: Boolean, onClick: () -> Unit) {
val context = LocalContext.current
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) {
- if (!isActive) player.pause()
+ if (isActive) player.play() else player.pause()
}
DisposableEffect(player) {
val listener = object : Player.Listener {
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)
onDispose {
@@ -540,12 +629,7 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) {
}
}
- Box(
- modifier = Modifier
- .fillMaxSize()
- .clickable { if (player.isPlaying) player.pause() else player.play() },
- contentAlignment = Alignment.Center
- ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
@@ -555,6 +639,21 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) {
},
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) {
Icon(
imageVector = Icons.Default.PlayCircle,
diff --git a/app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt b/app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt
index 663e7a8..c0796fc 100644
--- a/app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt
+++ b/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.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -363,33 +364,40 @@ fun HomeScreen() {
// =========================================
// PROFILE
// =========================================
+ // =========================================
+// PROFILE
+// =========================================
4 -> {
- // 1. Obtain instance of ViewModel and LocalContext
val profileViewModel: ProfileViewModel = viewModel()
val context = LocalContext.current
+ val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
- // 2. Collect state from ViewModel
val uiState by profileViewModel.profileUiState.collectAsState()
- // 3. Trigger network call once when screen loads
- LaunchedEffect(Unit) {
- profileViewModel.profileResponse(context)
+ // Automatically reload profile data when entering this tab OR resuming from another activity
+ DisposableEffect(lifecycleOwner) {
+ 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) {
is ProfileUiState.Loading, ProfileUiState.Idle -> {
MyProfileScreen(
profileResponse = null,
isLoading = true,
errorMessage = null,
-// onRetry = { viewModel.meetUserResponse(context) }
)
}
is ProfileUiState.Success -> {
MyProfileScreen(
- profileResponse = state.profileData, // or state.response depending on your data class naming
+ profileResponse = state.profileData,
isLoading = false,
errorMessage = null,
)
diff --git a/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt b/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt
index e64574b..2a649d2 100644
--- a/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt
+++ b/app/src/main/java/com/example/dattingapp/homes/MeetScreen.kt
@@ -1,9 +1,16 @@
package com.example.dattingapp.homes
+import android.Manifest
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.LocationManager
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.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -11,6 +18,9 @@ import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.filled.*
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.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
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.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.example.dattingapp.FriendProfile
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.SwipeViewModel
import com.example.dattingapp.homes.networkandapi.TokenManager
@@ -66,11 +83,25 @@ fun MeetDiscoveryScreen(
onSuperLikeClick: (MeetUserProfile) -> Unit = {},
onRejectClick: (MeetUserProfile) -> Unit = {},
onLikeClick: (MeetUserProfile) -> Unit = {},
- swipeViewModel: SwipeViewModel = viewModel()
+ swipeViewModel: SwipeViewModel = viewModel(),
+ searchViewModel: SearchViewModel = viewModel()
) {
val context = LocalContext.current
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(
modifier = modifier
.fillMaxSize()
@@ -112,7 +143,10 @@ fun MeetDiscoveryScreen(
color = BrandPurple
)
- IconButton(onClick = onSearchClick) {
+ IconButton(onClick = {
+ showSearchPanel = !showSearchPanel
+ onSearchClick()
+ }) {
Icon(
imageVector = Icons.Outlined.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
Box(
modifier = Modifier
@@ -131,20 +176,20 @@ fun MeetDiscoveryScreen(
contentAlignment = Alignment.Center
) {
when {
- isLoading -> {
+ activeLoading -> {
CircularProgressIndicator(color = BrandPink)
}
- errorMessage != null -> {
+ activeError != null -> {
Text(
- text = errorMessage,
+ text = activeError,
color = MaterialTheme.colorScheme.error,
fontSize = 16.sp
)
}
- profiles.isNotEmpty() && currentIndex < profiles.size -> {
- val currentProfile = profiles[currentIndex]
+ activeProfiles.isNotEmpty() && currentIndex < activeProfiles.size -> {
+ val currentProfile = activeProfiles[currentIndex]
ProfileCardItem(
profile = currentProfile,
@@ -168,7 +213,7 @@ fun MeetDiscoveryScreen(
else -> {
Text(
- text = "No more profiles available!",
+ text = if (searchActive) "No more results" else "No more profiles available!",
fontSize = 16.sp,
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(null) }
+ var lng by remember { mutableStateOf(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
private fun ProfileCardItem(
profile: MeetUserProfile,
@@ -348,8 +656,7 @@ private fun ProfileCardItem(
text = "SUPER LIKE",
color = Color(0xFF3498DB),
alpha = superAlpha,
- modifier = Modifier
- .align(Alignment.Center)
+ modifier = Modifier.align(Alignment.Center)
)
// ACTION BUTTONS (Right Side)
@@ -515,7 +822,7 @@ private fun SwipeStamp(
modifier = modifier.graphicsLayer { this.alpha = alpha },
color = Color.Transparent,
shape = RoundedCornerShape(8.dp),
- border = androidx.compose.foundation.BorderStroke(3.dp, color)
+ border = BorderStroke(3.dp, color)
) {
Text(
text = text,
diff --git a/app/src/main/java/com/example/dattingapp/homes/MyProfile.kt b/app/src/main/java/com/example/dattingapp/homes/MyProfile.kt
index e4252b9..4757ba4 100644
--- a/app/src/main/java/com/example/dattingapp/homes/MyProfile.kt
+++ b/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.material.icons.Icons
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.MusicNote
+import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.TextSnippet
+import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.*
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.unit.dp
import androidx.compose.ui.unit.sp
+import coil.ImageLoader
import coil.compose.AsyncImage
+import coil.decode.VideoFrameDecoder
import com.example.dattingapp.LikeANdMatchActivity
+import com.example.dattingapp.MatchesActivity
import com.example.dattingapp.R
import com.example.dattingapp.ShowPostingActivity
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.Media
import com.example.dattingapp.homes.profile.Post
@@ -85,6 +92,14 @@ fun MyProfileScreen(
val deletedIds by PostInteractionStore.deletedIds.collectAsState()
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(
modifier = Modifier
.fillMaxSize()
@@ -332,9 +347,7 @@ fun MyProfileScreen(
label = "Matches",
modifier = Modifier.weight(1f),
onClick = {
- val intent = Intent(context, LikeANdMatchActivity::class.java).apply {
- putExtra("TYPE", "Matches")
- }
+ val intent = Intent(context, MatchesActivity ::class.java)
context.startActivity(intent)
onMatchClick()
}
@@ -344,15 +357,27 @@ fun MyProfileScreen(
Spacer(modifier = Modifier.height(20.dp))
// ====================================================
- // POSTED SECTION
+ // POSTED SECTION — header now includes the post count
// ====================================================
- Text(
- text = "Posted",
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- color = textDark,
- modifier = Modifier.padding(horizontal = 16.dp)
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ 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))
ProfilePostsGrid(posts = visiblePosts) // CHANGED: deleted posts are filtered out
@@ -454,11 +479,75 @@ private fun ProfilePostsGrid(posts: List) {
}
}
+// 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
private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
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(
shape = RoundedCornerShape(12.dp),
@@ -475,9 +564,12 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
) {
Box(modifier = Modifier.fillMaxSize()) {
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(
model = primaryMedia,
- contentDescription = "Post Image",
+ imageLoader = videoAwareImageLoader,
+ contentDescription = if (isVideo) "Post Video" else "Post Image",
contentScale = ContentScale.Crop,
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(
modifier = Modifier
.align(Alignment.BottomStart)
@@ -516,6 +620,8 @@ private fun MasonryProfilePostItem(post: Post, aspectRatio: Float) {
overflow = TextOverflow.Ellipsis
)
}
+ // NEW: image tiles now show like + comment counts too
+ LikeCommentRow(post)
}
} else {
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(
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(2.dp)
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)
) {
Icon(
- imageVector = Icons.Default.Favorite,
- contentDescription = null,
- tint = Color.White.copy(alpha = 0.8f),
- modifier = Modifier.size(9.dp)
+ imageVector = Icons.Default.Collections,
+ contentDescription = "Multiple media",
+ tint = Color.White,
+ modifier = Modifier.size(11.dp)
)
Text(
- text = "${post.like_count}",
- color = Color.White.copy(alpha = 0.8f),
- fontSize = 8.sp
+ text = "$mediaCount",
+ color = Color.White,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold
)
}
}
diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt
index 70e2737..69820e4 100644
--- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt
+++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt
@@ -15,6 +15,7 @@ import retrofit2.http.Multipart
import retrofit2.http.Part
import com.example.dattingapp.homes.profile.UserResponse
import com.example.dattingapp.homes.profile.LikedUserResponse
+import com.example.dattingapp.homes.profile.MatchListItem
import retrofit2.http.DELETE
interface HomeApiService {
@@ -78,6 +79,13 @@ interface HomeApiService {
suspend fun getLikedUsers(
): Response>
+
+ @GET("api/v1/matches")
+ suspend fun getMatchList(
+ @Query("limit") limit: Int = 20,
+ @Query("offset") offset: Int = 0
+ ): Response>
+
@GET("/api/v1/matches")
suspend fun getMatches(
@Query("limit") limit: Int = 20,
@@ -128,4 +136,31 @@ interface HomeApiService {
@Path("post_id") postId: Int
): Response
+ @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>
+
+
+ @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>
+
+ @DELETE("api/v1/explore/posts/{post_id}/like")
+ suspend fun unlikePost(
+ @Path("post_id") postId: Int
+ ): Response
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt
index 27d2fba..b9649cf 100644
--- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt
+++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt
@@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
private const val MEDIA_BASE_URL = "https://melove.youthome.com.cn"
@@ -88,18 +90,27 @@ data class PostAllLikerResponse(
@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?
) {
+ // ADD everything below ↓
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
@@ -321,4 +332,22 @@ fun rememberPostInteraction(postId: Int): PostInteraction? {
PostInteractionStore.state.map { it[postId] }.distinctUntilChanged()
}
return flow.collectAsState(initial = PostInteractionStore.state.value[postId]).value
-}
\ No newline at end of file
+}
+
+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 = emptyList()
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt
index c250486..37a311a 100644
--- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt
+++ b/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
-
object AppLog {
const val TAG = "PostFlow" // ← the ONLY tag you filter in Logcat
}
diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt
index d441699..575365d 100644
--- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt
+++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt
@@ -25,6 +25,8 @@ import java.io.File
import java.io.FileOutputStream
import java.lang.Exception
import com.example.dattingapp.homes.profile.LikedUserResponse
+import com.example.dattingapp.homes.profile.MatchListItem
+import kotlinx.coroutines.Job
import org.json.JSONObject
sealed class MeetUiState {
@@ -798,19 +800,20 @@ class ShowPostViewModel : ViewModel() {
}
// 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
- // 1. Optimistic update: feed AND details screens change instantly
+ // 1. Optimistic update
PostInteractionStore.markLikePending(postId)
PostInteractionStore.toggleLike(postId)
- // 2. Server request
+ // 2. Server request: DELETE to unlike, POST to like
viewModelScope.launch {
try {
- val response = HomeNetworkModule
- .getApiService(appContext)
- .toggleLikePost(postId)
+ val api = HomeNetworkModule.getApiService(appContext)
+ val response =
+ if (currentlyLiked) api.unlikePost(postId)
+ else api.toggleLikePost(postId)
if (response.isSuccessful && response.body() != null) {
val serverData = response.body()!!
@@ -818,7 +821,6 @@ class ShowPostViewModel : ViewModel() {
postId, serverData.likedByMe, serverData.likeCount
)
- // keep this screen's own post state in sync too (if loaded)
val s = _showPostUiState.value
if (s is ShowPostUiState.Success && s.post.id == postId) {
_showPostUiState.value = ShowPostUiState.Success(
@@ -829,11 +831,11 @@ class ShowPostViewModel : ViewModel() {
)
}
} 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)
}
} 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)
} finally {
PostInteractionStore.clearLikePending(postId)
@@ -841,6 +843,8 @@ class ShowPostViewModel : ViewModel() {
}
}
+
+
fun fetchPostLikers(context: Context, postId: Int) {
val appContext = context.applicationContext
viewModelScope.launch {
@@ -949,6 +953,8 @@ class ShowPostViewModel : ViewModel() {
}
}
}
+
+
}
sealed interface FeedUiState {
@@ -1014,4 +1020,214 @@ class ExploreFeedViewModel : ViewModel() {
}
}
}
+}
+
+
+
+sealed interface SearchUiState {
+ object Idle : SearchUiState
+ object Loading : SearchUiState
+ data class Success(val results: List) : SearchUiState
+ data class Error(val message: String) : SearchUiState
+}
+
+class SearchViewModel : ViewModel() {
+ private val _state = MutableStateFlow(SearchUiState.Idle)
+ val state: StateFlow = _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) : PostSearchUiState
+ data class Error(val message: String) : PostSearchUiState
+}
+
+class PostSearchViewModel : ViewModel() {
+ private val _state = MutableStateFlow(PostSearchUiState.Idle)
+ val state: StateFlow = _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) : MatchesUiState
+ data class Error(val message: String) : MatchesUiState
+}
+
+class MatchesViewModel : ViewModel() {
+
+ private val _state = MutableStateFlow(MatchesUiState.Idle)
+ val state: StateFlow = _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}")
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/dattingapp/homes/profile/UserResponseData.kt b/app/src/main/java/com/example/dattingapp/homes/profile/UserResponseData.kt
index 2c7e0b7..b9abd41 100644
--- a/app/src/main/java/com/example/dattingapp/homes/profile/UserResponseData.kt
+++ b/app/src/main/java/com/example/dattingapp/homes/profile/UserResponseData.kt
@@ -104,4 +104,30 @@ data class LikedUserResponse(
val distance_km: Double?,
val compatibility_percentage: Int,
val media: List // 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
)
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2df7fac..def0123 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -91,6 +91,7 @@
UploadIntroVideoActivity
LikeANdMatchActivity
ShowPostingActivity
+ MatchesActivity
\ No newline at end of file