From a2ae9c4b4299beeab2b118f64a029718f42a556e Mon Sep 17 00:00:00 2001 From: osman160128 Date: Mon, 19 Oct 2026 14:28:18 +0600 Subject: [PATCH] 10/19/2026 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 6 + .../com/example/dattingapp/FriendProfile.kt | 450 ++++--------- .../dattingapp/LikeANdMatchActivity.kt | 51 +- .../com/example/dattingapp/MatchesActivity.kt | 224 +++++++ .../example/dattingapp/ShowPostingActivity.kt | 174 +++++- .../example/dattingapp/dataclass/DataClass.kt | 26 +- .../dattingapp/homes/CreatePostScreen.kt | 108 ++-- .../example/dattingapp/homes/ExploreScreen.kt | 591 ++++++++++-------- .../example/dattingapp/homes/HomeActivity.kt | 26 +- .../example/dattingapp/homes/MeetScreen.kt | 329 +++++++++- .../com/example/dattingapp/homes/MyProfile.kt | 167 ++++- .../homes/networkandapi/HomeApiService.kt | 35 ++ .../homes/networkandapi/HomeDataClass.kt | 47 +- .../homes/networkandapi/HomeNetwokModule.kt | 1 - .../homes/networkandapi/HomeViewModelClass.kt | 234 ++++++- .../homes/profile/UserResponseData.kt | 26 + app/src/main/res/values/strings.xml | 1 + 18 files changed, 1766 insertions(+), 731 deletions(-) create mode 100644 app/src/main/java/com/example/dattingapp/MatchesActivity.kt 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