This is a dating app
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

601 lines
21 KiB

package com.example.dattingapp.homes
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Search
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.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.SwipeRequest
import com.example.dattingapp.homes.networkandapi.SwipeViewModel
import com.example.dattingapp.homes.networkandapi.TokenManager
import com.example.dattingapp.ui.theme.BrandPink
import com.example.dattingapp.ui.theme.BrandPurple
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.Period
import java.time.format.DateTimeFormatter
import kotlin.math.abs
// Direction strings sent to the backend. CHECK "superlike" matches what your API expects.
private const val DIR_LIKE = "like"
private const val DIR_PASS = "pass"
private const val DIR_SUPER_LIKE = "superlike"
@Composable
fun MeetDiscoveryScreen(
modifier: Modifier = Modifier,
profiles: List<MeetUserProfile> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null,
onAddClick: () -> Unit = {},
onSearchClick: () -> Unit = {},
onSuperLikeClick: (MeetUserProfile) -> Unit = {},
onRejectClick: (MeetUserProfile) -> Unit = {},
onLikeClick: (MeetUserProfile) -> Unit = {},
swipeViewModel: SwipeViewModel = viewModel()
) {
val context = LocalContext.current
var currentIndex by remember { mutableIntStateOf(0) }
Box(
modifier = modifier
.fillMaxSize()
.background(Color(0xFFF8F9FA))
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// TOP BAR
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onAddClick,
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(Color(0xFFEBEBEB))
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add",
tint = BrandPink,
modifier = Modifier.size(20.dp)
)
}
Text(
text = "VibeCheck",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = BrandPurple
)
IconButton(onClick = onSearchClick) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "Search",
tint = BrandPink,
modifier = Modifier.size(26.dp)
)
}
}
// MAIN CONTENT CARD
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(bottom = 16.dp),
contentAlignment = Alignment.Center
) {
when {
isLoading -> {
CircularProgressIndicator(color = BrandPink)
}
errorMessage != null -> {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
fontSize = 16.sp
)
}
profiles.isNotEmpty() && currentIndex < profiles.size -> {
val currentProfile = profiles[currentIndex]
ProfileCardItem(
profile = currentProfile,
onSuperLike = {
handleSwipeAction(context, DIR_SUPER_LIKE, currentProfile, swipeViewModel)
onSuperLikeClick(currentProfile)
currentIndex++
},
onReject = {
handleSwipeAction(context, DIR_PASS, currentProfile, swipeViewModel)
onRejectClick(currentProfile)
currentIndex++
},
onLike = {
handleSwipeAction(context, DIR_LIKE, currentProfile, swipeViewModel)
onLikeClick(currentProfile)
currentIndex++
}
)
}
else -> {
Text(
text = "No more profiles available!",
fontSize = 16.sp,
color = Color.Gray
)
}
}
}
}
}
}
@Composable
private fun ProfileCardItem(
profile: MeetUserProfile,
onSuperLike: () -> Unit,
onReject: () -> Unit,
onLike: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Drag offsets. Reset whenever the profile changes.
val offsetX = remember(profile) { Animatable(0f) }
val offsetY = remember(profile) { Animatable(0f) }
// Distance (in px) the card must be dragged before it counts as a swipe
val threshold = with(LocalDensity.current) { 120.dp.toPx() }
val flyDistance = with(LocalDensity.current) { 700.dp.toPx() }
val photoUrl = remember(profile) {
profile.media
?.firstOrNull { it.mediaType.equals("image", ignoreCase = true) || it.url != null }
?.fullMediaUrl
}
val age = remember(profile.birthDate) {
calculateAge(profile.birthDate)
}
val locationText = remember(profile) {
val loc = listOfNotNull(profile.city, profile.country).joinToString(", ")
val dist = profile.distanceKm?.let { "${it.toInt()} km away" }
listOfNotNull(dist, loc.takeIf { it.isNotBlank() }).joinToString(" • ")
}
// How strongly each stamp should show (0f..1f), based on the current drag
val x = offsetX.value
val y = offsetY.value
val verticalDominant = abs(y) > abs(x)
val likeAlpha = if (!verticalDominant) (x / threshold).coerceIn(0f, 1f) else 0f
val nopeAlpha = if (!verticalDominant) (-x / threshold).coerceIn(0f, 1f) else 0f
val superAlpha = if (verticalDominant) (-y / threshold).coerceIn(0f, 1f) else 0f
Box(
modifier = Modifier
.fillMaxSize()
// pointerInput comes BEFORE graphicsLayer so drag deltas are not
// measured in the moved/rotated coordinates (that would cause jitter)
.pointerInput(profile) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
scope.launch {
offsetX.snapTo(offsetX.value + dragAmount.x)
offsetY.snapTo(offsetY.value + dragAmount.y)
}
},
onDragEnd = {
scope.launch {
val dx = offsetX.value
val dy = offsetY.value
when {
// UP -> super like
dy < -threshold && abs(dy) > abs(dx) -> {
offsetY.animateTo(-flyDistance)
onSuperLike()
}
// RIGHT -> like
dx > threshold -> {
offsetX.animateTo(flyDistance)
onLike()
}
// LEFT -> reject
dx < -threshold -> {
offsetX.animateTo(-flyDistance)
onReject()
}
// Not far enough: snap back
else -> {
launch { offsetX.animateTo(0f) }
launch { offsetY.animateTo(0f) }
}
}
}
},
onDragCancel = {
scope.launch {
launch { offsetX.animateTo(0f) }
launch { offsetY.animateTo(0f) }
}
}
)
}
.graphicsLayer {
translationX = offsetX.value
translationY = offsetY.value
rotationZ = offsetX.value / 40f // slight tilt while dragging sideways
}
.clip(RoundedCornerShape(20.dp))
) {
val token = remember(context) {
TokenManager.getInstance(context).getAccessToken()
}
val defaultPainter = rememberVectorPainter(Icons.Default.Person)
if (!photoUrl.isNullOrBlank()) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(photoUrl)
.apply {
if (!token.isNullOrBlank()) {
val authHeader =
if (token.startsWith("Bearer ", ignoreCase = true)) token
else "Bearer $token"
addHeader("Authorization", authHeader)
}
}
.crossfade(true)
.build(),
placeholder = defaultPainter,
error = defaultPainter,
contentDescription = "Profile Image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
onError = { errorState ->
Log.e("CoilStatus", "Failed to load image", errorState.result.throwable)
}
)
} else {
Image(
painter = defaultPainter,
contentDescription = "Profile Image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
// DARK GRADIENT OVERLAY
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f)),
startY = 350f
)
)
)
// SWIPE STAMPS (fade in as you drag)
SwipeStamp(
text = "LIKE",
color = Color(0xFF2ECC71),
alpha = likeAlpha,
modifier = Modifier
.align(Alignment.TopStart)
.padding(24.dp)
)
SwipeStamp(
text = "NOPE",
color = Color(0xFFE74C3C),
alpha = nopeAlpha,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(24.dp)
)
SwipeStamp(
text = "SUPER LIKE",
color = Color(0xFF3498DB),
alpha = superAlpha,
modifier = Modifier
.align(Alignment.Center)
)
// ACTION BUTTONS (Right Side)
Column(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 14.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Super Like (Star)
IconButton(
onClick = onSuperLike,
modifier = Modifier
.size(50.dp)
.shadow(6.dp, CircleShape)
.clip(CircleShape)
.background(Color.White)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Super Like",
tint = BrandPurple,
modifier = Modifier.size(24.dp)
)
}
// Reject (X)
IconButton(
onClick = onReject,
modifier = Modifier
.size(54.dp)
.shadow(6.dp, CircleShape)
.clip(CircleShape)
.background(Color.White)
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Reject",
tint = Color(0xFF555555),
modifier = Modifier.size(26.dp)
)
}
// Like (Heart)
IconButton(
onClick = onLike,
modifier = Modifier
.size(60.dp)
.shadow(8.dp, CircleShape)
.clip(CircleShape)
.background(BrandPink)
) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "Like",
tint = Color.White,
modifier = Modifier.size(30.dp)
)
}
}
// PROFILE DETAILS (Bottom Left)
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
.fillMaxWidth(0.70f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val intent = Intent(context, FriendProfile::class.java).apply {
putExtra("USER_ID", profile.id)
}
context.startActivity(intent)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val displayName = profile.fullName ?: "User"
val nameAndAge = if (age != null) "$displayName, $age" else displayName
Text(
text = nameAndAge,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
profile.compatibilityPercentage?.let { match ->
Surface(
shape = RoundedCornerShape(10.dp),
color = BrandPink
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(
imageVector = Icons.Default.Verified,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(10.dp)
)
Text(
text = "$match%",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
if (locationText.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(14.dp)
)
Text(
text = locationText,
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.9f)
)
}
}
profile.bio?.takeIf { it.isNotBlank() }?.let { bio ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = bio,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.85f),
maxLines = 2
)
}
}
}
}
// The LIKE / NOPE / SUPER LIKE label that fades in while dragging
@Composable
private fun SwipeStamp(
text: String,
color: Color,
alpha: Float,
modifier: Modifier = Modifier
) {
if (alpha <= 0f) return
Surface(
modifier = modifier.graphicsLayer { this.alpha = alpha },
color = Color.Transparent,
shape = RoundedCornerShape(8.dp),
border = androidx.compose.foundation.BorderStroke(3.dp, color)
) {
Text(
text = text,
color = color,
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
}
// Helper to calculate age from birth date string (e.g., "1999-05-15")
private fun calculateAge(birthDateStr: String?): Int? {
if (birthDateStr.isNullOrBlank()) return null
return try {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val birthDate = LocalDate.parse(birthDateStr.take(10), formatter)
Period.between(birthDate, LocalDate.now()).years
} catch (e: Exception) {
null
}
}
// Sends the swipe (like / pass / superlike) to the server
private fun handleSwipeAction(
context: Context,
direction: String,
profile: MeetUserProfile,
swipeViewModel: SwipeViewModel
) {
val swipeRequest = SwipeRequest(profile.id, direction)
swipeViewModel.sendSwipeResponse(context, swipeRequest)
}
// ==========================================
// PREVIEWS & MOCK DATA
// ==========================================
private val mockProfiles = listOf(
MeetUserProfile(
id = 1,
fullName = "Sophia Chen",
birthDate = "1998-06-15",
city = "San Francisco",
country = "USA",
distanceKm = 4.2,
bio = "Coffee enthusiast, UI designer, and full-time dog lover 🐶✨ Looking for someone to explore new coffee shops with!",
compatibilityPercentage = 92,
media = null,
gender = "Male",
videoUrl = null
),
MeetUserProfile(
id = 2,
fullName = "Alex Rivera",
birthDate = "1995-11-03",
city = "Los Angeles",
country = "USA",
distanceKm = 12.0,
bio = "Software engineer by day, musician by night 🎸 Let's jam or talk about tech!",
compatibilityPercentage = 85,
media = null,
gender = "Male",
videoUrl = null
)
)
@Preview(name = "Discovery Screen - Loaded State", showBackground = true)
@Composable
fun MeetDiscoveryScreenPreview() {
MaterialTheme {
MeetDiscoveryScreen(
profiles = mockProfiles,
isLoading = false,
errorMessage = null,
onAddClick = {},
onSearchClick = {},
onSuperLikeClick = {},
onRejectClick = {},
onLikeClick = {}
)
}
}