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
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 = {}
|
|
)
|
|
}
|
|
}
|