|
|
@ -1,11 +1,13 @@ |
|
|
package com.example.dattingapp.homes |
|
|
package com.example.dattingapp.homes |
|
|
|
|
|
|
|
|
import android.content.Intent |
|
|
import android.content.Intent |
|
|
|
|
|
import android.util.Log |
|
|
import androidx.compose.foundation.BorderStroke |
|
|
import androidx.compose.foundation.BorderStroke |
|
|
import androidx.media3.common.MediaItem as ExoMediaItem |
|
|
import androidx.media3.common.MediaItem as ExoMediaItem |
|
|
import androidx.compose.foundation.background |
|
|
import androidx.compose.foundation.background |
|
|
import androidx.compose.foundation.border |
|
|
import androidx.compose.foundation.border |
|
|
import androidx.compose.foundation.clickable |
|
|
import androidx.compose.foundation.clickable |
|
|
|
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource |
|
|
import androidx.compose.foundation.layout.* |
|
|
import androidx.compose.foundation.layout.* |
|
|
import androidx.compose.foundation.lazy.LazyColumn |
|
|
import androidx.compose.foundation.lazy.LazyColumn |
|
|
import androidx.compose.foundation.lazy.LazyRow |
|
|
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.platform.LocalContext |
|
|
import androidx.compose.ui.res.painterResource |
|
|
import androidx.compose.ui.res.painterResource |
|
|
import androidx.compose.ui.text.TextStyle |
|
|
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.font.FontWeight |
|
|
import androidx.compose.ui.text.style.TextAlign |
|
|
import androidx.compose.ui.text.style.TextAlign |
|
|
import androidx.compose.ui.text.style.TextOverflow |
|
|
import androidx.compose.ui.text.style.TextOverflow |
|
|
import androidx.compose.ui.tooling.preview.Preview |
|
|
import androidx.compose.ui.tooling.preview.Preview |
|
|
|
|
|
import androidx.compose.ui.unit.TextUnit |
|
|
import androidx.compose.ui.unit.dp |
|
|
import androidx.compose.ui.unit.dp |
|
|
import androidx.compose.ui.unit.sp |
|
|
import androidx.compose.ui.unit.sp |
|
|
import androidx.compose.ui.viewinterop.AndroidView |
|
|
import androidx.compose.ui.viewinterop.AndroidView |
|
|
import androidx.lifecycle.viewmodel.compose.viewModel |
|
|
import androidx.lifecycle.viewmodel.compose.viewModel |
|
|
|
|
|
import androidx.media3.common.PlaybackException |
|
|
import androidx.media3.common.Player |
|
|
import androidx.media3.common.Player |
|
|
import androidx.media3.exoplayer.ExoPlayer |
|
|
import androidx.media3.exoplayer.ExoPlayer |
|
|
import androidx.media3.ui.PlayerView |
|
|
import androidx.media3.ui.PlayerView |
|
|
import coil.compose.AsyncImage |
|
|
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.R |
|
|
import com.example.dattingapp.ShowPostingActivity |
|
|
import com.example.dattingapp.ShowPostingActivity |
|
|
import com.example.dattingapp.dataclass.Author |
|
|
import com.example.dattingapp.dataclass.Author |
|
|
import com.example.dattingapp.dataclass.Location |
|
|
import com.example.dattingapp.dataclass.Location |
|
|
import com.example.dattingapp.dataclass.MediaItem |
|
|
import com.example.dattingapp.dataclass.MediaItem |
|
|
import com.example.dattingapp.dataclass.ShowPostResponse |
|
|
import com.example.dattingapp.dataclass.ShowPostResponse |
|
|
import com.example.dattingapp.homes.networkandapi.PostInteractionStore // NEW |
|
|
import com.example.dattingapp.homes.networkandapi.PostInteractionStore |
|
|
import com.example.dattingapp.homes.networkandapi.rememberPostInteraction // NEW |
|
|
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.ShowPostViewModel |
|
|
import com.example.dattingapp.homes.networkandapi.TokenManager |
|
|
import com.example.dattingapp.homes.networkandapi.TokenManager |
|
|
import com.example.dattingapp.ui.theme.DattingAPPTheme |
|
|
import com.example.dattingapp.ui.theme.DattingAPPTheme |
|
|
@ -76,40 +86,85 @@ fun VibeCheckHomeScreen( |
|
|
isLoading: Boolean, |
|
|
isLoading: Boolean, |
|
|
errorMessage: String?, |
|
|
errorMessage: String?, |
|
|
onRetry: () -> Unit = {}, |
|
|
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) { |
|
|
LaunchedEffect(feedResponse) { |
|
|
feedResponse.forEach { |
|
|
feedResponse.forEach { |
|
|
PostInteractionStore.setFromServer(it.id, it.liked_by_me, it.like_count, it.comment_count) |
|
|
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( |
|
|
Scaffold( |
|
|
modifier = Modifier.fillMaxSize(), |
|
|
modifier = Modifier.fillMaxSize(), |
|
|
topBar = { |
|
|
topBar = { |
|
|
VibeCheckTopAppBar(primaryColor) |
|
|
VibeCheckTopAppBar( |
|
|
|
|
|
primaryColor = primaryColor, |
|
|
|
|
|
onSearchClick = { |
|
|
|
|
|
if (showSearch) searchViewModel.clearSearch() // closing clears the search |
|
|
|
|
|
showSearch = !showSearch |
|
|
|
|
|
} |
|
|
|
|
|
) |
|
|
} |
|
|
} |
|
|
) { innerPadding -> |
|
|
) { innerPadding -> |
|
|
LazyColumn( |
|
|
Column( |
|
|
modifier = Modifier |
|
|
modifier = Modifier |
|
|
.fillMaxSize() |
|
|
.fillMaxSize() |
|
|
.padding(innerPadding) |
|
|
.padding(innerPadding) |
|
|
.padding(horizontal = 16.dp), |
|
|
.padding(horizontal = 4.dp) |
|
|
verticalArrangement = Arrangement.spacedBy(16.dp) |
|
|
|
|
|
) { |
|
|
) { |
|
|
item { |
|
|
if (showSearch) { |
|
|
SearchBarSection() |
|
|
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 -> |
|
|
when (val s = searchState) { |
|
|
FeaturedEventCard( |
|
|
is PostSearchUiState.Loading -> Box( |
|
|
post = post, |
|
|
Modifier |
|
|
badgeColor = primaryColor |
|
|
.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 { |
|
|
// No search active: normal feed |
|
|
Spacer(modifier = Modifier.height(16.dp)) |
|
|
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 |
|
|
@Composable |
|
|
private fun VibeCheckTopAppBar( |
|
|
private fun VibeCheckTopAppBar( |
|
|
primaryColor: Color |
|
|
primaryColor: Color, |
|
|
|
|
|
onSearchClick: () -> Unit |
|
|
) { |
|
|
) { |
|
|
val context = LocalContext.current |
|
|
val context = LocalContext.current |
|
|
val tokenManager = remember { TokenManager.getInstance(context) } |
|
|
val tokenManager = remember { TokenManager.getInstance(context) } |
|
|
@ -160,7 +216,7 @@ private fun VibeCheckTopAppBar( |
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
IconButton(onClick = { }) { |
|
|
IconButton(onClick = onSearchClick) { |
|
|
Icon( |
|
|
Icon( |
|
|
imageVector = Icons.Default.Search, |
|
|
imageVector = Icons.Default.Search, |
|
|
contentDescription = "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 |
|
|
@Composable |
|
|
private fun VibeFilterChipsSection( |
|
|
private fun VibeFilterChipsSection( |
|
|
selectedColor: Color, |
|
|
selectedColor: Color, |
|
|
@ -239,7 +246,7 @@ private fun VibeFilterChipsSection( |
|
|
) { |
|
|
) { |
|
|
items(categories, key = { it.id }) { category -> |
|
|
items(categories, key = { it.id }) { category -> |
|
|
Surface( |
|
|
Surface( |
|
|
shape = RoundedCornerShape(16.dp), |
|
|
shape = RoundedCornerShape(12.dp), |
|
|
color = if (category.isSelected) selectedColor else unselectedBg, |
|
|
color = if (category.isSelected) selectedColor else unselectedBg, |
|
|
modifier = Modifier.height(32.dp) |
|
|
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 |
|
|
@Composable |
|
|
private fun FeaturedEventCard( |
|
|
private fun FeaturedEventCard( |
|
|
post: ShowPostResponse, |
|
|
post: ShowPostResponse, |
|
|
badgeColor: Color, |
|
|
badgeColor: Color, |
|
|
|
|
|
category: String? = null, // e.g. "Coffee Crawl Series" — no field in ShowPostResponse yet |
|
|
showPostViewModel: ShowPostViewModel = viewModel() |
|
|
showPostViewModel: ShowPostViewModel = viewModel() |
|
|
) { |
|
|
) { |
|
|
val context = LocalContext.current |
|
|
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 live = rememberPostInteraction(post.id) |
|
|
val likedByMe = live?.likedByMe ?: post.liked_by_me |
|
|
val likedByMe = live?.likedByMe ?: post.liked_by_me |
|
|
val likeCount = live?.likeCount ?: post.like_count |
|
|
val likeCount = live?.likeCount ?: post.like_count |
|
|
val commentCount = live?.commentCount ?: post.comment_count |
|
|
val commentCount = live?.commentCount ?: post.comment_count |
|
|
|
|
|
|
|
|
val hasMedia = post.media.isNotEmpty() |
|
|
val hasMedia = post.media.isNotEmpty() |
|
|
val tags = listOfNotNull(post.feeling, post.location?.name) |
|
|
|
|
|
|
|
|
|
|
|
val contentColor = if (hasMedia) Color.White else TextPostTextColor |
|
|
// Hashtags are pulled out of the text; the body shows the text without them. |
|
|
val shadowStyle = if (hasMedia) TextStyle(shadow = OverlayShadow) else TextStyle.Default |
|
|
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( |
|
|
val openDetails: () -> Unit = { |
|
|
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 intent = Intent(context, ShowPostingActivity::class.java).apply { |
|
|
val intent = Intent(context, ShowPostingActivity::class.java).apply { |
|
|
putExtra("Post_ID", post.id) |
|
|
putExtra("Post_ID", post.id) |
|
|
} |
|
|
} |
|
|
context.startActivity(intent) |
|
|
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 |
|
|
Card( |
|
|
Box( |
|
|
shape = RoundedCornerShape(16.dp), |
|
|
|
|
|
border = BorderStroke(1.dp, Color(0xFFEDEDED)), |
|
|
|
|
|
colors = CardDefaults.cardColors(containerColor = Color.White), |
|
|
|
|
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), |
|
|
modifier = Modifier |
|
|
modifier = Modifier |
|
|
.fillMaxWidth() |
|
|
.fillMaxWidth() |
|
|
.height(90.dp) |
|
|
.clickable { openDetails() } |
|
|
.align(Alignment.TopCenter) |
|
|
) { |
|
|
.background( |
|
|
Column(modifier = Modifier.padding(12.dp)) { |
|
|
Brush.verticalGradient( |
|
|
|
|
|
listOf(Color.Black.copy(alpha = 0.45f), Color.Transparent) |
|
|
|
|
|
) |
|
|
|
|
|
) |
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
// 3. Bottom scrim |
|
|
// ---- 1. Header: avatar, name, feeling, time · location ---- |
|
|
Box( |
|
|
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 |
|
|
modifier = Modifier |
|
|
.fillMaxWidth() |
|
|
.size(40.dp) |
|
|
.height(180.dp) |
|
|
.clip(CircleShape) |
|
|
.align(Alignment.BottomCenter) |
|
|
|
|
|
.background( |
|
|
|
|
|
Brush.verticalGradient( |
|
|
|
|
|
listOf(Color.Transparent, Color.Black.copy(alpha = 0.8f)) |
|
|
|
|
|
) |
|
|
) |
|
|
|
|
|
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( |
|
|
Spacer(Modifier.height(2.dp)) |
|
|
text = post.content.orEmpty(), |
|
|
Row(verticalAlignment = Alignment.CenterVertically) { |
|
|
color = TextPostTextColor, |
|
|
OptionalText( |
|
|
fontSize = if ((post.content?.length ?: 0) > 120) 18.sp else 24.sp, |
|
|
value = timeAgo(post.createdAt), |
|
|
fontWeight = FontWeight.Bold, |
|
|
placeholder = "Unknown time", |
|
|
textAlign = TextAlign.Center, |
|
|
fontSize = 11.sp, |
|
|
maxLines = 8, |
|
|
color = Color.Gray, |
|
|
overflow = TextOverflow.Ellipsis, |
|
|
maxLines = 1 |
|
|
modifier = Modifier |
|
|
|
|
|
.align(Alignment.Center) |
|
|
|
|
|
.padding(horizontal = 32.dp) |
|
|
|
|
|
) |
|
|
) |
|
|
|
|
|
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( |
|
|
Surface( |
|
|
color = badgeColor, |
|
|
color = Color.Black.copy(alpha = 0.55f), |
|
|
shape = RoundedCornerShape(12.dp), |
|
|
shape = RoundedCornerShape(8.dp), |
|
|
modifier = Modifier |
|
|
modifier = Modifier |
|
|
.padding(12.dp) |
|
|
.align(Alignment.BottomStart) |
|
|
.align(Alignment.TopStart) |
|
|
.padding(8.dp) |
|
|
) { |
|
|
) { |
|
|
Row( |
|
|
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( |
|
|
Icon( |
|
|
imageVector = when (post.postType) { |
|
|
imageVector = Icons.Outlined.Image, |
|
|
"video" -> Icons.Default.Videocam |
|
|
|
|
|
"image" -> Icons.Default.Image |
|
|
|
|
|
else -> Icons.Default.TextFields |
|
|
|
|
|
}, |
|
|
|
|
|
contentDescription = null, |
|
|
contentDescription = null, |
|
|
tint = Color.White, |
|
|
tint = Color.White, |
|
|
modifier = Modifier.size(12.dp) |
|
|
modifier = Modifier.size(12.dp) |
|
|
) |
|
|
) |
|
|
Spacer(Modifier.width(4.dp)) |
|
|
Spacer(Modifier.width(4.dp)) |
|
|
Text( |
|
|
Text( |
|
|
text = post.postType?.uppercase() ?: "POST", |
|
|
text = category?.takeIf { it.isNotBlank() } ?: "No category", |
|
|
color = Color.White, |
|
|
color = Color.White, |
|
|
fontSize = 10.sp, |
|
|
fontStyle = if (category.isNullOrBlank()) FontStyle.Italic else FontStyle.Normal, |
|
|
fontWeight = FontWeight.Bold |
|
|
fontSize = 11.sp, |
|
|
|
|
|
maxLines = 1, |
|
|
|
|
|
overflow = TextOverflow.Ellipsis |
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// 5. Like / comment pillar |
|
|
// ---- 4. Hashtags ---- |
|
|
Column( |
|
|
Spacer(Modifier.height(10.dp)) |
|
|
modifier = Modifier |
|
|
if (hashtags.isEmpty()) { |
|
|
.align(Alignment.BottomEnd) |
|
|
OptionalText(value = null, placeholder = "No hashtags", fontSize = 12.sp) |
|
|
.padding(end = 12.dp, bottom = 16.dp) |
|
|
} else { |
|
|
.clip(RoundedCornerShape(24.dp)) |
|
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { |
|
|
.padding(vertical = 4.dp, horizontal = 2.dp), |
|
|
items(hashtags) { tag -> |
|
|
horizontalAlignment = Alignment.CenterHorizontally, |
|
|
Surface(color = lightChipBg, shape = RoundedCornerShape(8.dp)) { |
|
|
verticalArrangement = Arrangement.spacedBy(16.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( |
|
|
Icon( |
|
|
imageVector = if (likedByMe) Icons.Default.Favorite else Icons.Outlined.FavoriteBorder, |
|
|
imageVector = if (likedByMe) Icons.Default.Favorite else Icons.Outlined.FavoriteBorder, |
|
|
contentDescription = "Like", |
|
|
contentDescription = "Like", |
|
|
tint = if (likedByMe) Color.Red else contentColor, |
|
|
tint = if (likedByMe) Color.Red else Color.Gray, |
|
|
modifier = Modifier.size(28.dp) |
|
|
modifier = Modifier.size(20.dp) |
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
Text( |
|
|
|
|
|
text = "$likeCount", |
|
|
|
|
|
color = contentColor, |
|
|
|
|
|
fontSize = 12.sp, |
|
|
|
|
|
style = shadowStyle |
|
|
|
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
Text("$likeCount", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) |
|
|
|
|
|
|
|
|
|
|
|
Spacer(Modifier.width(14.dp)) |
|
|
|
|
|
|
|
|
// Comment + count |
|
|
|
|
|
Column(horizontalAlignment = Alignment.CenterHorizontally) { |
|
|
|
|
|
Icon( |
|
|
Icon( |
|
|
imageVector = Icons.Outlined.ChatBubbleOutline, |
|
|
imageVector = Icons.Outlined.ChatBubbleOutline, |
|
|
contentDescription = "Comment", |
|
|
contentDescription = "Comment", |
|
|
tint = contentColor, |
|
|
tint = Color.Gray, |
|
|
modifier = Modifier.size(28.dp) |
|
|
modifier = Modifier.size(20.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 |
|
|
|
|
|
) |
|
|
) |
|
|
|
|
|
Spacer(Modifier.width(4.dp)) |
|
|
|
|
|
Text("$commentCount", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) |
|
|
|
|
|
|
|
|
if (hasMedia && !post.content.isNullOrBlank()) { |
|
|
Spacer(Modifier.weight(1f)) |
|
|
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 |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (tags.isNotEmpty()) { |
|
|
Icon( |
|
|
Spacer(Modifier.height(8.dp)) |
|
|
imageVector = Icons.Outlined.Share, |
|
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { |
|
|
contentDescription = "Share", |
|
|
tags.forEach { tag -> |
|
|
tint = Color.Gray, |
|
|
Surface( |
|
|
modifier = Modifier.size(20.dp) |
|
|
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) |
|
|
|
|
|
) |
|
|
) |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@Composable |
|
|
@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 }) |
|
|
val pagerState = rememberPagerState(pageCount = { media.size }) |
|
|
|
|
|
|
|
|
Box(modifier) { |
|
|
Box(modifier) { |
|
|
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> |
|
|
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> |
|
|
val item = media[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 (item.mediaType == "video") { |
|
|
|
|
|
if (resolvedUrl != null) { |
|
|
PostVideoPlayer( |
|
|
PostVideoPlayer( |
|
|
url = item.url, |
|
|
url = resolvedUrl, |
|
|
isActive = pagerState.currentPage == page |
|
|
isActive = pagerState.currentPage == page, |
|
|
|
|
|
onClick = onVideoClick |
|
|
) |
|
|
) |
|
|
|
|
|
} else { |
|
|
|
|
|
Log.e("PostMediaPager", "Video item ${item.id} has no usable URL: ${item.url}") |
|
|
|
|
|
} |
|
|
} else { |
|
|
} else { |
|
|
AsyncImage( |
|
|
AsyncImage( |
|
|
model = item.url, |
|
|
model = resolvedUrl, |
|
|
contentDescription = "Post media ${page + 1}", |
|
|
contentDescription = "Post media ${page + 1}", |
|
|
contentScale = ContentScale.Crop, |
|
|
contentScale = ContentScale.Crop, |
|
|
modifier = Modifier.fillMaxSize() |
|
|
modifier = Modifier.fillMaxSize() |
|
|
@ -513,7 +592,7 @@ private fun PostMediaPager(media: List<MediaItem>, modifier: Modifier = Modifier |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@Composable |
|
|
@Composable |
|
|
private fun PostVideoPlayer(url: String, isActive: Boolean) { |
|
|
private fun PostVideoPlayer(url: String, isActive: Boolean, onClick: () -> Unit) { |
|
|
val context = LocalContext.current |
|
|
val context = LocalContext.current |
|
|
var isPlaying by remember { mutableStateOf(false) } |
|
|
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) { |
|
|
LaunchedEffect(isActive) { |
|
|
if (!isActive) player.pause() |
|
|
if (isActive) player.play() else player.pause() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
DisposableEffect(player) { |
|
|
DisposableEffect(player) { |
|
|
val listener = object : Player.Listener { |
|
|
val listener = object : Player.Listener { |
|
|
override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing } |
|
|
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) |
|
|
player.addListener(listener) |
|
|
onDispose { |
|
|
onDispose { |
|
|
@ -540,12 +629,7 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
Box( |
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
|
|
modifier = Modifier |
|
|
|
|
|
.fillMaxSize() |
|
|
|
|
|
.clickable { if (player.isPlaying) player.pause() else player.play() }, |
|
|
|
|
|
contentAlignment = Alignment.Center |
|
|
|
|
|
) { |
|
|
|
|
|
AndroidView( |
|
|
AndroidView( |
|
|
factory = { ctx -> |
|
|
factory = { ctx -> |
|
|
PlayerView(ctx).apply { |
|
|
PlayerView(ctx).apply { |
|
|
@ -555,6 +639,21 @@ private fun PostVideoPlayer(url: String, isActive: Boolean) { |
|
|
}, |
|
|
}, |
|
|
modifier = Modifier.fillMaxSize() |
|
|
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) { |
|
|
if (!isPlaying) { |
|
|
Icon( |
|
|
Icon( |
|
|
imageVector = Icons.Default.PlayCircle, |
|
|
imageVector = Icons.Default.PlayCircle, |
|
|
|