Browse Source

10/19/2026

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

1
app/build.gradle.kts

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

6
app/src/main/AndroidManifest.xml

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

380
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,21 +362,20 @@ 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
// ❤️ Like & ⭐ Super Like Buttons — Hidden when matched
if (showLikeAndSuperLike) {
// ❤️ Like Button
Card(
onClick = {
onLikeClick()
handleLikeAndDislikeAction(context, "right", friendProfileData, swipeViewModel)
localLiked = true // ✅ disable after first click
handleLikeAndDislikeAction(context, "like", friendProfileData, swipeViewModel)
localLiked = true
matchesViewModel.loadMatches(context)
},
enabled = !likeDisabled,
modifier = Modifier.size(48.dp),
@ -383,43 +393,55 @@ fun FriendProfileScreen(
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
heLikedMe -> Color.White
likeDisabled -> Color(0xFFBDBDBD)
else -> textDark
},
modifier = Modifier.size(20.dp)
)
}
}
// Super Like — card style
// ⭐ Super Like (Star) Button
Card(
onClick = {
onStarClick()
handleLikeAndDislikeAction(context, "right", friendProfileData, swipeViewModel)
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),
elevation = CardDefaults.cardElevation(defaultElevation = 2.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 = "Favorite",
tint = textDark,
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<FriendPost>) {
// Distribute posts into two columns
val columns = remember(posts) {
val col1 = mutableListOf<FriendPost>()
val col2 = mutableListOf<FriendPost>()
@ -475,24 +494,17 @@ fun StaggeredFriendPostsGrid(posts: List<FriendPost>) {
}
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<FriendPost>) {
@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,
@ -688,232 +687,3 @@ private fun calculateAge(birthDateStr: String?): Int? {
null
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun FriendProfileScreenPreview() {
DattingAPPTheme {
val mockProfileResponse = FriendProfileResponse(
id = 101,
full_name = "Elena Rostova",
gender = "Female",
birth_date = "1999-05-14",
city = "San Francisco",
county = "San Francisco County",
country = "USA",
relationship_goal = "Long-term relationship",
bio = "Always looking for the best flat white in the city. Amateur photographer, professional dog petter.",
profile_image_url = "https://via.placeholder.com/150",
video_url = null,
distance_km = 2.5,
compatibility_percentage = 98,
media = emptyList(),
interests = listOf("Travel", "Photography", "Coffee"),
posts = emptyList() // Posts are handled by postUiState in your screen
)
val mockPosts = listOf(
PostResponse(
id = 1,
content = "Cupidatat fugiat voluptate",
postType = "image",
author = PostAuthor(
id = 101,
fullName = "Elena Rostova",
city = "San Francisco",
country = "USA",
compatibilityPercentage = 98
),
likeCount = 12,
commentCount = 2,
likedByMe = false,
media = listOf("https://via.placeholder.com/300"),
createdAt = "2026-10-09T03:19:49"
),
PostResponse(
id = 2,
content = "Museum hopping & iced matcha 🍵🎨",
postType = "vibe",
author = PostAuthor(
id = 101,
fullName = "Elena Rostova",
city = "San Francisco",
country = "USA",
compatibilityPercentage = 98
),
likeCount = 34,
commentCount = 5,
likedByMe = true,
media = emptyList(),
createdAt = "2026-10-10T02:00:00"
),
PostResponse(
id = 3,
content = "Friday night dancing 🔮✨",
postType = "music",
author = PostAuthor(
id = 101,
fullName = "Elena Rostova",
city = "San Francisco",
country = "USA",
compatibilityPercentage = 98
),
likeCount = 45,
commentCount = 8,
likedByMe = false,
media = emptyList(),
createdAt = "2026-10-11T05:00:00"
)
)
FriendProfileScreen(
friendProfileData = mockProfileResponse,
context = LocalContext.current,
postUiState = PostUiState.Success(mockPosts),
onBackClick = {},
onMoreClick = {},
onMessageClick = {},
onLikeClick = {},
onStarClick = {},
onCancelClick = {}
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun StaggeredFriendPostsGridPreview() {
DattingAPPTheme {
val mockAuthor = FriendAuthor(
id = 1,
full_name = "Mock User",
city = "Mock City",
country = "Mock Country",
compatibility_percentage = 90
)
val mockPosts = listOf(
// Image Post - Aspect Ratio 0.75f (Tall)
FriendPost(
id = 1,
content = "Beautiful sunset!",
post_type = "image",
content_format = "plain",
feeling = "peaceful",
location = null,
author = mockAuthor,
like_count = 120,
comment_count = 15,
liked_by_me = false,
media = listOf(
FrienPostMedia(
id = 1,
media_type = "image",
mime_type = "image/jpeg",
size_bytes = 1024,
position = 0,
url = "https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwyMDkyMnwwfDF8c2VhcmNofDF8fHN1bnNldHxlbnwwfHx8fDE2NzQwMzUyMzA&ixlib=rb-1.2.1&q=80&w=400"
)
),
created_at = "2023-10-27T10:00:00Z"
),
// Text Post - Aspect Ratio 1.0f (Square)
FriendPost(
id = 2,
content = "Thinking about Kotlin Coroutines...",
post_type = "text",
content_format = "plain",
feeling = "thoughtful",
location = null,
author = mockAuthor,
like_count = 45,
comment_count = 5,
liked_by_me = true,
media = emptyList(),
created_at = "2023-10-27T09:30:00Z"
),
// Music Post - Aspect Ratio 1.1f (Slightly Wide)
FriendPost(
id = 3,
content = "Now Playing: 'Bohemian Rhapsody' by Queen",
post_type = "music",
content_format = "plain",
feeling = "energetic",
location = null,
author = mockAuthor,
like_count = 89,
comment_count = 10,
liked_by_me = false,
media = emptyList(),
created_at = "2023-10-27T09:00:00Z"
),
// Image Post - Aspect Ratio 1.25f (Wide)
FriendPost(
id = 4,
content = "Coffee time!",
post_type = "image",
content_format = "plain",
feeling = "happy",
location = null,
author = mockAuthor,
like_count = 210,
comment_count = 25,
liked_by_me = false,
media = listOf(
FrienPostMedia(
id = 2,
media_type = "image",
mime_type = "image/png",
size_bytes = 2048,
position = 0,
url = "https://images.unsplash.com/photo-1509042239860-f550ce710b93?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwyMDkyMnwwfDF8c2VhcmNofDF8fGNvZmZlZXxlbnwwfHx8fDE2NzQwMzUzMzA&ixlib=rb-1.2.1&q=80&w=400"
)
),
created_at = "2023-10-27T08:30:00Z"
),
// Vibe Post - Aspect Ratio 0.8f (Slightly Tall)
FriendPost(
id = 5,
content = "Just enjoying the quiet evening.",
post_type = "vibe",
content_format = "plain",
feeling = "calm",
location = null,
author = mockAuthor,
like_count = 60,
comment_count = 8,
liked_by_me = true,
media = emptyList(),
created_at = "2023-10-27T08:00:00Z"
),
// Image Post - Aspect Ratio 0.7f (Very Tall)
FriendPost(
id = 6,
content = "City lights",
post_type = "image",
content_format = "plain",
feeling = "amazed",
location = null,
author = mockAuthor,
like_count = 150,
comment_count = 18,
liked_by_me = false,
media = listOf(
FrienPostMedia(
id = 3,
media_type = "image",
mime_type = "image/jpeg",
size_bytes = 3072,
position = 0,
url = "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwyMDkyMnwwfDF8c2VhcmNofDF8fGNpdHklMjBsaWdodHN8ZW58MHx8fHwxNjc0MDM1NDMw&ixlib=rb-1.2.1&q=80&w=400"
)
),
created_at = "2023-10-27T07:30:00Z"
)
)
StaggeredFriendPostsGrid(posts = mockPosts)
}
}

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

@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.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

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

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

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

@ -10,7 +10,10 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.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<MediaItem>, modifier: Modifier = Modifier) {
val pagerState = rememberPagerState(pageCount = { media.size })
Box(modifier) {
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val item = media[page]
val resolvedUrl = resolveMediaUrl(item.url)
if (item.mediaType == "video") {
if (resolvedUrl != null) {
PostVideoPlayer(
url = resolvedUrl,
isActive = pagerState.currentPage == page
)
} else {
Log.e("PostMediaPager", "Video item ${item.id} has no usable URL: ${item.url}")
}
} else {
AsyncImage(
model = resolvedUrl,
contentDescription = "Post media ${page + 1}",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
// "1/3" counter badge — only when there's more than one media item
if (media.size > 1) {
Surface(
color = Color.Black.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
) {
Text(
text = "${pagerState.currentPage + 1}/${media.size}",
color = Color.White,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
@Composable
private fun PostVideoPlayer(url: String, isActive: Boolean) {
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
val player = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(ExoMediaItem.fromUri(url))
repeatMode = Player.REPEAT_MODE_ONE
prepare()
}
}
LaunchedEffect(isActive) {
if (isActive) player.play() else player.pause()
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing }
override fun onPlayerError(error: PlaybackException) {
Log.e(
"PostVideoPlayer",
"Playback failed for $url: ${error.errorCodeName} — ${error.message}",
error
)
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = false
}
},
modifier = Modifier.fillMaxSize()
)
// Tap layer above the PlayerView to toggle play/pause
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
if (player.isPlaying) player.pause() else player.play()
}
)
if (!isPlaying) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = "Play",
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(64.dp)
)
}
}
}
@Composable
fun CommentSection(
comments: List<CommentResponse>,
@ -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"

26
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

50
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,13 +86,20 @@ fun CreateVibePostScreen(
val maxChar = 500
val selectedMediaList = remember { mutableStateListOf<MediaContentItem>() }
val post_type = remember(selectedMediaList) {
// 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"
}
}
}
var tempCameraImageUri by remember { mutableStateOf<Uri?>(null) }
var tempCameraVideoUri by remember { mutableStateOf<Uri?>(null) }
@ -120,11 +129,19 @@ fun CreateVibePostScreen(
val postId = state.postData.id
Log.d("CreatePostDebug", "Post created with ID: $postId")
val errors = mutableListOf<String>()
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()
}
@ -294,7 +311,6 @@ fun CreateVibePostScreen(
Toast.makeText(context, "Add text or at least one photo/video", Toast.LENGTH_SHORT).show()
return@Button
}
// ... build request and call createPost()
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,6 +436,9 @@ fun CreateVibePostScreen(
Spacer(modifier = Modifier.height(4.dp))
// 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
@ -442,11 +461,18 @@ fun CreateVibePostScreen(
imageVector = Icons.Default.Close,
contentDescription = "Remove location",
tint = Color.Gray,
modifier = Modifier.size(16.dp)
modifier = Modifier
.size(16.dp)
.clickable(enabled = !isPosting) {
latitude = null
longitude = null
showLocationTag = false
}
)
}
}
}
}
Spacer(modifier = Modifier.height(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() }
}

545
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)
)
// 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)) }
}
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()
}
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
),
modifier = Modifier
.fillMaxWidth()
.height(360.dp)
.clickable {
val openDetails: () -> Unit = {
val intent = Intent(context, ShowPostingActivity::class.java).apply {
putExtra("Post_ID", post.id)
}
context.startActivity(intent)
}
) {
Box(modifier = Modifier.fillMaxSize()) {
// 1. Content: swipeable media, or big centered text
if (hasMedia) {
PostMediaPager(media = post.media, modifier = Modifier.fillMaxSize())
// 2. Top scrim
Box(
Card(
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color(0xFFEDEDED)),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.align(Alignment.TopCenter)
.background(
Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.45f), Color.Transparent)
)
)
)
.clickable { openDetails() }
) {
Column(modifier = Modifier.padding(12.dp)) {
// 3. Bottom 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(180.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.8f))
.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
)
} 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)
}
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
)
// 4. Badge
// ---- 3. Media with category label (text-only posts skip this block) ----
if (hasMedia) {
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
) {
PostMediaPager(
media = post.media,
onVideoClick = openDetails,
modifier = Modifier.fillMaxSize()
)
Surface(
color = badgeColor,
shape = RoundedCornerShape(12.dp),
color = Color.Black.copy(alpha = 0.55f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.padding(12.dp)
.align(Alignment.TopStart)
.align(Alignment.BottomStart)
.padding(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = when (post.postType) {
"video" -> Icons.Default.Videocam
"image" -> Icons.Default.Image
else -> Icons.Default.TextFields
},
imageVector = Icons.Outlined.Image,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = post.postType?.uppercase() ?: "POST",
text = category?.takeIf { it.isNotBlank() } ?: "No category",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
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)
// ---- 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)
)
}
}
}
}
// ---- 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)
) {
// 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)
)
}
Text(
text = "$likeCount",
color = contentColor,
fontSize = 12.sp,
style = shadowStyle
tint = if (likedByMe) Color.Red else Color.Gray,
modifier = Modifier.size(20.dp)
)
}
Text("$likeCount", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(14.dp))
// Comment + count
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Outlined.ChatBubbleOutline,
contentDescription = "Comment",
tint = contentColor,
modifier = Modifier.size(28.dp)
)
Text(
text = "$commentCount",
color = contentColor,
fontSize = 12.sp,
style = shadowStyle
)
}
}
// 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
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<MediaItem>, modifier: Modifier = Modifier) {
private fun PostMediaPager(
media: List<MediaItem>,
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") {
if (resolvedUrl != null) {
PostVideoPlayer(
url = item.url,
isActive = pagerState.currentPage == page
url = resolvedUrl,
isActive = pagerState.currentPage == page,
onClick = onVideoClick
)
} else {
Log.e("PostMediaPager", "Video item ${item.id} has no usable URL: ${item.url}")
}
} else {
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<MediaItem>, 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,

24
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,20 +364,28 @@ 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) {
// 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)
}
// 4. Map UI state to Composable parameters
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
when (val state = uiState) {
is ProfileUiState.Loading, ProfileUiState.Idle -> {
@ -384,12 +393,11 @@ fun HomeScreen() {
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,
)

329
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<Double?>(null) }
var lng by remember { mutableStateOf<Double?>(null) }
var showFilters by remember { mutableStateOf(false) }
val activeFilters = listOf(city.isNotBlank(), country.isNotBlank(), lat != null).count { it }
fun doSearch() = onSearch(q, city, country, lat, lng, km.toDoubleOrNull() ?: 20.0)
fun fetchLocation() {
try {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val loc = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.filter { lm.isProviderEnabled(it) }
.firstNotNullOfOrNull { lm.getLastKnownLocation(it) }
if (loc != null) {
lat = loc.latitude
lng = loc.longitude
} else {
Toast.makeText(context, "Unable to get location. Turn on GPS.", Toast.LENGTH_SHORT).show()
}
} catch (e: SecurityException) {
Toast.makeText(context, "Location permission not granted", Toast.LENGTH_SHORT).show()
}
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) fetchLocation() }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Normal search bar + small filter button beside it
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Compact search bar (40dp tall)
Row(
modifier = Modifier
.weight(1f)
.height(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFFEBEBEB))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
if (q.isEmpty()) {
Text(
text = "Search interests, e.g. hiking",
color = Color.Gray,
fontSize = 14.sp,
maxLines = 1
)
}
BasicTextField(
value = q,
onValueChange = { q = it },
singleLine = true,
textStyle = TextStyle(fontSize = 14.sp, color = Color(0xFF1E1E1E)),
cursorBrush = SolidColor(BrandPink),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { doSearch() }),
modifier = Modifier.fillMaxWidth()
)
}
if (q.isNotEmpty() || showClear) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Clear",
tint = Color.Gray,
modifier = Modifier
.size(18.dp)
.clickable {
q = ""
city = ""
country = ""
lat = null
lng = null
onClear()
}
)
}
}
// Small filter button (matches the bar height)
IconButton(
onClick = { showFilters = !showFilters },
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (showFilters || activeFilters > 0) BrandPink.copy(alpha = 0.15f)
else Color(0xFFEBEBEB)
)
) {
BadgedBox(
badge = {
if (activeFilters > 0) {
Badge(containerColor = BrandPink) { Text("$activeFilters") }
}
}
) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "Filters",
tint = BrandPink,
modifier = Modifier.size(20.dp)
)
}
}
}
// Filter options, shown only when the filter button is tapped
if (showFilters) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CompactField(
value = city,
onValueChange = { city = it },
placeholder = "City",
modifier = Modifier.weight(1f)
)
CompactField(
value = country,
onValueChange = { country = it },
placeholder = "Country",
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Small "Near me" pill
Row(
modifier = Modifier
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(if (lat != null) BrandPink.copy(alpha = 0.15f) else Color(0xFFEBEBEB))
.clickable {
if (lat != null) {
lat = null
lng = null
} else if (ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
fetchLocation()
} else {
permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = if (lat != null) BrandPink else Color.Gray,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = "Near me",
fontSize = 13.sp,
color = if (lat != null) BrandPink else Color(0xFF555555)
)
}
if (lat != null) {
CompactField(
value = km,
onValueChange = { km = it.filter { c -> c.isDigit() || c == '.' } },
placeholder = "km",
keyboardType = KeyboardType.Number,
modifier = Modifier.width(70.dp)
)
}
Spacer(Modifier.weight(1f))
// Small Apply button
Button(
onClick = { doSearch(); showFilters = false },
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = 18.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = BrandPink)
) {
Text("Apply", fontSize = 13.sp)
}
}
}
}
}
@Composable
private fun CompactField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Text
) {
Box(
modifier = modifier
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color(0xFFEBEBEB))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
Text(placeholder, color = Color.Gray, fontSize = 13.sp, maxLines = 1)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = TextStyle(fontSize = 13.sp, color = Color(0xFF1E1E1E)),
cursorBrush = SolidColor(BrandPink),
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
modifier = Modifier.fillMaxWidth()
)
}
}
// ==========================================
// SWIPE CARD
// ==========================================
@Composable
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,

157
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
// ====================================================
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,
modifier = Modifier.padding(horizontal = 16.dp)
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<Post>) {
}
}
// Small live like/comment row used on every tile
@Composable
private fun LikeCommentRow(post: Post) {
// Live values from the shared store; fall back to the values that came
// with the profile response.
val live = rememberPostInteraction(post.id)
val likeCount = live?.likeCount ?: post.like_count
val commentCount = live?.commentCount ?: post.comment_count
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Likes",
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(9.dp)
)
Text(
text = "$likeCount",
color = Color.White.copy(alpha = 0.8f),
fontSize = 8.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(
imageVector = Icons.Outlined.ChatBubbleOutline,
contentDescription = "Comments",
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(9.dp)
)
Text(
text = "$commentCount",
color = Color.White.copy(alpha = 0.8f),
fontSize = 8.sp
)
}
}
}
@Composable
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
)
}
}

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

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

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

@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.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
@ -322,3 +333,21 @@ fun rememberPostInteraction(postId: Int): PostInteraction? {
}
return flow.collectAsState(initial = PostInteractionStore.state.value[postId]).value
}
data class SearchUserResponse(
@SerializedName("id") val id: Int,
@SerializedName("full_name") val fullName: String?,
@SerializedName("city") val city: String?,
@SerializedName("country") val country: String?,
@SerializedName("profile_image_url") val profileImageUrl: String?,
@SerializedName("compatibility_percentage") val compatibilityPercentage: Int?,
@SerializedName("distance_km") val distanceKm: Double?
)
// Response data model matching your Apifox screenshot
data class UnLikePostResponse(
val message: String,
val like_count: Int,
val liked_by_me: Boolean,
val liked_by_users: List<Any> = emptyList()
)

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

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

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

@ -25,6 +25,8 @@ import java.io.File
import java.io.FileOutputStream
import java.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 {
@ -1015,3 +1021,213 @@ class ExploreFeedViewModel : ViewModel() {
}
}
}
sealed interface SearchUiState {
object Idle : SearchUiState
object Loading : SearchUiState
data class Success(val results: List<MeetUserProfile>) : SearchUiState
data class Error(val message: String) : SearchUiState
}
class SearchViewModel : ViewModel() {
private val _state = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
val state: StateFlow<SearchUiState> = _state.asStateFlow()
private var job: Job? = null
fun clearSearch() {
job?.cancel()
_state.value = SearchUiState.Idle
}
fun search(
context: Context,
q: String?, city: String?, country: String?,
latitude: Double?, longitude: Double?, maxDistanceKm: Double?
) {
val appContext = context.applicationContext
val query = q?.trim()?.ifEmpty { null }
val cityArg = city?.trim()?.ifEmpty { null }
val countryArg = country?.trim()?.ifEmpty { null }
val nearby = latitude != null && longitude != null
if (query == null && cityArg == null && countryArg == null && !nearby) {
_state.value = SearchUiState.Error("Enter a keyword, city, country or use your location.")
return
}
job?.cancel() // a new search replaces the old one
job = viewModelScope.launch {
_state.value = SearchUiState.Loading
try {
val response = HomeNetworkModule.getApiService(appContext).searchUsers(
q = query,
city = cityArg,
country = countryArg,
latitude = if (nearby) latitude else null,
longitude = if (nearby) longitude else null,
maxDistanceKm = if (nearby) maxDistanceKm else null,
limit = 20
)
if (response.isSuccessful) {
val list = response.body().orEmpty()
_state.value =
if (list.isEmpty()) SearchUiState.Error("No results found.")
else SearchUiState.Success(list)
} else {
val err = response.errorBody()?.string()
Log.e("SearchApi", "HTTP ${response.code()}: $err url=${response.raw().request.url}")
_state.value = SearchUiState.Error(
when (response.code()) {
401 -> "Authentication failed. Please log in again."
422 -> "Invalid search parameters."
else -> "Search failed: HTTP ${response.code()}."
}
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e("SearchApi", "Search failed", e)
_state.value = SearchUiState.Error("Request failed: ${e.javaClass.simpleName}")
}
}
}
}
sealed interface PostSearchUiState {
object Idle : PostSearchUiState
object Loading : PostSearchUiState
data class Success(val posts: List<ShowPostResponse>) : PostSearchUiState
data class Error(val message: String) : PostSearchUiState
}
class PostSearchViewModel : ViewModel() {
private val _state = MutableStateFlow<PostSearchUiState>(PostSearchUiState.Idle)
val state: StateFlow<PostSearchUiState> = _state.asStateFlow()
private var job: kotlinx.coroutines.Job? = null
fun search(
context: Context,
q: String?, city: String?, country: String?,
latitude: Double?, longitude: Double?, maxDistanceKm: Double?
) {
val appContext = context.applicationContext
val query = q?.trim()?.ifEmpty { null }
val cityArg = city?.trim()?.ifEmpty { null }
val countryArg = country?.trim()?.ifEmpty { null }
val nearby = latitude != null && longitude != null
if (query == null && cityArg == null && countryArg == null && !nearby) {
_state.value = PostSearchUiState.Error("Enter a keyword, city, country or use your location.")
return
}
job?.cancel()
job = viewModelScope.launch {
_state.value = PostSearchUiState.Loading
try {
val response = HomeNetworkModule.getApiService(appContext).searchPosts(
q = query,
city = cityArg,
country = countryArg,
latitude = if (nearby) latitude else null,
longitude = if (nearby) longitude else null,
maxDistanceKm = if (nearby) maxDistanceKm else null,
limit = 20
)
if (response.isSuccessful) {
val list = response.body().orEmpty()
if (list.isEmpty()) {
_state.value = PostSearchUiState.Error("No posts found.")
} else {
// keep like/comment counts in sync with the shared store
list.forEach {
PostInteractionStore.setFromServer(
it.id, it.liked_by_me, it.like_count, it.comment_count
)
}
_state.value = PostSearchUiState.Success(list)
}
} else {
val err = response.errorBody()?.string()
Log.e("PostSearch", "HTTP ${response.code()}: $err url=${response.raw().request.url}")
_state.value = PostSearchUiState.Error(
when (response.code()) {
401 -> "Authentication failed. Please log in again."
404 -> "Search endpoint not found."
422 -> "Invalid search parameters."
else -> "Search failed: HTTP ${response.code()}."
}
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Log.e("PostSearch", "Search failed", e)
_state.value = PostSearchUiState.Error("Request failed: ${e.javaClass.simpleName}")
}
}
}
fun clearSearch() {
job?.cancel()
_state.value = PostSearchUiState.Idle
}
}
sealed interface MatchesUiState {
object Idle : MatchesUiState
object Loading : MatchesUiState
data class Success(val matches: List<MatchListItem>) : MatchesUiState
data class Error(val message: String) : MatchesUiState
}
class MatchesViewModel : ViewModel() {
private val _state = MutableStateFlow<MatchesUiState>(MatchesUiState.Idle)
val state: StateFlow<MatchesUiState> = _state.asStateFlow()
fun loadMatches(context: Context) {
if (_state.value is MatchesUiState.Loading) return
val appContext = context.applicationContext
viewModelScope.launch {
_state.value = MatchesUiState.Loading
if (TokenManager.getInstance(appContext).getAccessToken().isNullOrBlank()) {
_state.value = MatchesUiState.Error("No access token found. Please log in again.")
return@launch
}
try {
val response = HomeNetworkModule.getApiService(appContext).getMatchList(20, 0)
Log.d("MatchesApi", "Response code: ${response.code()}")
if (response.isSuccessful) {
// drop entries with no user so the grid never sees a null user
val list = response.body().orEmpty().filter { it.user != null }
_state.value = MatchesUiState.Success(list)
} else {
val err = response.errorBody()?.string()
Log.e("MatchesApi", "HTTP ${response.code()}: $err")
_state.value = MatchesUiState.Error(
when (response.code()) {
401 -> "Authentication failed. Please log in again."
403 -> "The server refused access to matches."
404 -> "Matches endpoint was not found."
else -> "Request failed: HTTP ${response.code()}."
}
)
}
} catch (e: HttpException) {
_state.value = MatchesUiState.Error("Request failed: HTTP ${e.code()}.")
} catch (e: Exception) {
Log.e("MatchesApi", "Unexpected failure", e)
_state.value = MatchesUiState.Error("Request failed: ${e.javaClass.simpleName}")
}
}
}
}

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

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

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

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