diff --git a/app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt b/app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt index ccb4bdb..bf7c27f 100644 --- a/app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt +++ b/app/src/main/java/com/example/dattingapp/homes/CreatePostScreen.kt @@ -2,7 +2,13 @@ package com.example.dattingapp.homes import com.example.dattingapp.homes.networkandapi.Location import com.example.dattingapp.homes.networkandapi.CreatePostRequest +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager import android.net.Uri +import android.util.Log +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -14,6 +20,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow 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.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -33,6 +40,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle @@ -40,35 +48,23 @@ 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.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.MediaItem import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import coil.compose.rememberAsyncImagePainter +import com.example.dattingapp.homes.networkandapi.CreatePostUiState +import com.example.dattingapp.homes.networkandapi.CreatePostViewModel +import com.example.dattingapp.homes.networkandapi.PostMediaViewModel import com.example.dattingapp.homes.networkandapi.VibeFeeling import com.example.dattingapp.homes.networkandapi.vibeFeelingsList -import com.example.dattingapp.ui.theme.LightPinkBg -import com.example.dattingapp.ui.theme.LightPurpleBg import com.example.dattingapp.ui.theme.DarkOverlay -import com.example.dattingapp.ui.theme.LightBlueBg -import android.Manifest -import android.content.pm.PackageManager -import android.location.LocationManager -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.compose.foundation.lazy.grid.items -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.dattingapp.homes.networkandapi.CreatePostViewModel -import com.example.dattingapp.homes.networkandapi.CreatePostUiState -import android.util.Log - - -import android.content.Context -import androidx.compose.ui.text.TextStyle -import com.example.dattingapp.homes.networkandapi.PostMediaViewModel import com.example.dattingapp.ui.theme.DattingAPPTheme - +import com.example.dattingapp.ui.theme.LightBlueBg +import com.example.dattingapp.ui.theme.LightPinkBg +import com.example.dattingapp.ui.theme.LightPurpleBg import java.io.File // Sealed class representing selectable media types (Photo or Video) @@ -77,21 +73,17 @@ sealed class MediaContentItem(val uri: Uri) { class Video(uri: Uri) : MediaContentItem(uri) } - - @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateVibePostScreen( onCloseClick: () -> Unit = {}, - onShareClick: () -> Unit = {} + onShareClick: () -> Unit = {} // called ONLY after the post is created ) { val context = LocalContext.current var textState by remember { mutableStateOf("") } val maxChar = 500 - // Holds selected media items (photos, videos, or both) val selectedMediaList = remember { mutableStateListOf() } - // Determine the post type dynamically based on attached media val post_type = remember(selectedMediaList) { when { selectedMediaList.any { it is MediaContentItem.Video } -> "video" @@ -107,10 +99,60 @@ fun CreateVibePostScreen( var showFeelingSheet by remember { mutableStateOf(false) } var selectedFeeling by remember { mutableStateOf(null) } + // ---------------- ViewModels (moved to the top of the screen) ---------------- + val createPostViewModel: CreatePostViewModel = viewModel() + val postMediaViewModel: PostMediaViewModel = viewModel() + val uiState by createPostViewModel.cretePpostUiState.collectAsState() + + // True from the Share tap until the whole flow is done + val isPosting = uiState is CreatePostUiState.Loading || uiState is CreatePostUiState.Success + + // Location states (declared before the effect below, which clears them) + var latitude by remember { mutableStateOf(null) } + var longitude by remember { mutableStateOf(null) } + var showLocationTag by remember { mutableStateOf(false) } + + // React to the create-post result + LaunchedEffect(uiState) { + when (val state = uiState) { + is CreatePostUiState.Success -> { + try { + val postId = state.postData.id + Log.d("CreatePostDebug", "Post created with ID: $postId") + + val errors = mutableListOf() + selectedMediaList.toList().forEachIndexed { index, item -> + val err = postMediaViewModel.uploadMedia(context, postId, index, item.uri) + if (err != null) errors.add("File ${index + 1}: $err") + } + if (errors.isNotEmpty()) { + Toast.makeText(context, errors.first(), Toast.LENGTH_LONG).show() + } + textState = "" + selectedMediaList.clear() + selectedFeeling = null + latitude = null + longitude = null + showLocationTag = false + + onShareClick() + } finally { + // always runs, even if this effect is cancelled, so the button never stays stuck + createPostViewModel.reset() + } + } + is CreatePostUiState.Error -> { + Log.e("CreatePostDebug", "Error: ${state.message}") + Toast.makeText(context, state.message, Toast.LENGTH_LONG).show() + createPostViewModel.reset() + } - // Launcher for Multi-Media Gallery Picker (Supports both Photos and Videos simultaneously) + else -> {} + } + } + // ---------------- Pickers ---------------- val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia() ) { uris: List -> @@ -124,20 +166,14 @@ fun CreateVibePostScreen( } } - // Temporary file helper for Camera captures fun createTempFileUri(extension: String, prefix: String): Uri { val tempFile = File.createTempFile(prefix, extension, context.cacheDir).apply { createNewFile() deleteOnExit() } - return FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - tempFile - ) + return FileProvider.getUriForFile(context, "${context.packageName}.provider", tempFile) } - // Camera Launchers for taking photos or recording videos val cameraPhotoLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.TakePicture() ) { success: Boolean -> @@ -154,25 +190,17 @@ fun CreateVibePostScreen( } } - - // Location States - var latitude by remember { mutableStateOf(null) } - var longitude by remember { mutableStateOf(null) } - var showLocationTag by remember { mutableStateOf(false) } - - // Native Location Manager (Works in China without Google Play Services) + // ---------------- Location ---------------- val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager } - // Helper function to get location using native APIs fun fetchDeviceLocation() { try { val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) val isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) var location: android.location.Location? = null - if (isGpsEnabled) { location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } @@ -192,31 +220,23 @@ fun CreateVibePostScreen( } } - // Permission Launcher for the system dialog val locationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { isGranted -> - if (isGranted) { - fetchDeviceLocation() - } else { - Toast.makeText(context, "Location permission denied", Toast.LENGTH_SHORT).show() - } + if (isGranted) fetchDeviceLocation() + else Toast.makeText(context, "Location permission denied", Toast.LENGTH_SHORT).show() } - // Click handler for the location chip val onLocationChipClick: () -> Unit = { val hasPermission = ContextCompat.checkSelfPermission( context, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED - if (hasPermission) { - fetchDeviceLocation() - } else { - // Triggers the system "Allow / Deny" dialog - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } + if (hasPermission) fetchDeviceLocation() + else locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } + // ---------------- UI ---------------- Scaffold( bottomBar = { Row( @@ -227,7 +247,6 @@ fun CreateVibePostScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - // 1. Scrollable option chips on the left LazyRow( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -238,7 +257,7 @@ fun CreateVibePostScreen( icon = Icons.Outlined.PhotoLibrary, backgroundColor = LightPinkBg, contentColor = Color(0xFFE91E63), - onClick = { showImagePickerDialog = true } + onClick = { if (!isPosting) showImagePickerDialog = true } ) } item { @@ -246,7 +265,7 @@ fun CreateVibePostScreen( icon = Icons.Outlined.MyLocation, backgroundColor = LightBlueBg, contentColor = Color(0xFF1E88E5), - onClick = { onLocationChipClick() } + onClick = { if (!isPosting) onLocationChipClick() } ) } item { @@ -254,7 +273,7 @@ fun CreateVibePostScreen( icon = Icons.Outlined.Mood, backgroundColor = LightPinkBg, contentColor = Color(0xFFE91E63), - onClick = { showFeelingSheet = true } // Opens bottom sheet + onClick = { if (!isPosting) showFeelingSheet = true } ) } item { @@ -268,97 +287,52 @@ fun CreateVibePostScreen( Spacer(modifier = Modifier.width(8.dp)) - // 2. React to UI state changes (Handles post success and triggers media uploads) - // 2. Share button on the right - - val context = LocalContext.current - val createPostViewModel: CreatePostViewModel = viewModel() - val postMediaViewModel: PostMediaViewModel = viewModel() - val uiState by createPostViewModel.cretePpostUiState.collectAsState() - - LaunchedEffect(uiState) { - when (val state = uiState) { - is CreatePostUiState.Success -> { - val postId = state.postData.id - Log.d("CreatePostDebug", "Post created successfully with ID: $postId. Starting media uploads...") - - if (selectedMediaList.isEmpty()) { - Log.d("CreatePostDebug", "No media attached. Flow complete.") - onShareClick() // Navigate away or finish screen - } else { - // Loop through and upload media sequentially - for ((index, item) in selectedMediaList.withIndex()) { - val uri = when (item) { - is MediaContentItem.Photo -> item.uri - is MediaContentItem.Video -> item.uri - } - - val position = index + 1 // Position starts at 1 - - if (uri != null && uri.toString().isNotBlank()) { - Log.d("CreatePostDebug", "Uploading media at position $position...") - postMediaViewModel.poostMedia(context,postId,index,uri) - - // Call your media upload function here - // val isUploaded = createPostViewModel.uploadPostMediaItem(context, postId, position, uri) - } - } - Log.d("CreatePostDebug", "All media processed.") - onShareClick() // Navigate away or finish screen after media upload - } - } - is CreatePostUiState.Error -> { - Log.e("CreatePostDebug", "Error: ${state.message}") - } - else -> {} - } - } Button( + enabled = !isPosting, // blocks double taps onClick = { - // 1. Build the Location object if the tag is active and coordinates exist + if (textState.isBlank() && selectedMediaList.isEmpty()) { + Toast.makeText(context, "Add text or at least one photo/video", Toast.LENGTH_SHORT).show() + return@Button + } + // ... build request and call createPost() val postLocation = if (showLocationTag && latitude != null && longitude != null) { Location( - name = "Current Location", // Or your geocoded address string + name = "Current Location", latitude = latitude, longitude = longitude ) - } else { - null - } + } else null - // 2. Construct the request payload matching your data class val createPostRequest = CreatePostRequest( - content = textState, - post_type = post_type, // "text", "image", or "video" (dynamically derived) - content_format = "markdown", // Adjust based on your backend requirement - feeling = selectedFeeling?.let { "${it.name} ${it.emoji}" }, // Passing the selected feeling name/label + content = textState.trim().ifBlank { " " }, // ← the fix: " " instead of "" + post_type = post_type, + content_format = "plain", // keep markdown, it works + feeling = selectedFeeling?.let { "${it.name} ${it.emoji}" }, location = postLocation ) - - - // Simply trigger the ViewModel function inside onClick! + // Only start the request. The screen is closed by the + // LaunchedEffect above once the post is created. createPostViewModel.createPost(context, createPostRequest) - - - - - // 3. Pass the payload to your ViewModel or API repository callback - // onShareClick(requestPayload, selectedMediaList) - onShareClick() }, modifier = Modifier .wrapContentWidth() .height(40.dp), shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + disabledContainerColor = Color.Transparent + ), contentPadding = PaddingValues(0.dp) ) { Box( modifier = Modifier .background( brush = Brush.horizontalGradient( - colors = listOf(Color(0xFFC2185B), Color(0xFF7B1FA2)) + colors = if (isPosting) + listOf(Color(0xFFC2185B).copy(alpha = 0.5f), Color(0xFF7B1FA2).copy(alpha = 0.5f)) + else + listOf(Color(0xFFC2185B), Color(0xFF7B1FA2)) ), shape = RoundedCornerShape(12.dp) ) @@ -367,7 +341,7 @@ fun CreateVibePostScreen( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "Share", + text = if (isPosting) "Posting..." else "Share", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp @@ -387,71 +361,67 @@ fun CreateVibePostScreen( containerColor = Color.White ) { innerPadding -> - Column( + Box( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()) - ) { - // User Profile Header - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Box(modifier = Modifier.size(48.dp)) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Color.Gray) - ) - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(Color(0xFFE91E63)) - .align(Alignment.BottomEnd) - .border(2.dp, Color.White, CircleShape) - ) - } - Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + // User Profile Header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box(modifier = Modifier.size(48.dp)) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color.Gray) + ) + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(Color(0xFFE91E63)) + .align(Alignment.BottomEnd) + .border(2.dp, Color.White, CircleShape) + ) + } - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = buildAnnotatedString { - // "Alex Rivera " - Bold - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("Alex Rivera ") - } + Spacer(modifier = Modifier.width(12.dp)) - // "- feeling " - Normal weight (also fixed "felling" to "feeling") - selectedFeeling?.let { feeling -> - withStyle(style = SpanStyle(fontWeight = FontWeight.Normal)) { - append(" — feeling ") - } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - // Change feeling.name to feeling.label (or feeling.title depending on your data class) - append("${feeling.name} ${feeling.emoji}") + append("Alex Rivera ") } - } - }, - fontSize = 16.sp - ) - Spacer(modifier = Modifier.width(4.dp)) - - //this is for vibe - - } + selectedFeeling?.let { feeling -> + withStyle(style = SpanStyle(fontWeight = FontWeight.Normal)) { + append(" — feeling ") + } + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("${feeling.name} ${feeling.emoji}") + } + } + }, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.width(4.dp)) + } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier - .fillMaxWidth(), + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -475,134 +445,134 @@ fun CreateVibePostScreen( modifier = Modifier.size(16.dp) ) } - - - - + } } - } - Spacer(modifier = Modifier.height(16.dp)) - - // Text Area - TextField( - value = textState, - onValueChange = { if (it.length <= maxChar) textState = it }, - textStyle = TextStyle( - fontSize = 20.sp, - lineHeight = 28.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFF1E1E1E), - letterSpacing = 0.2.sp - ), - placeholder = { - Text( - "What's your vibe today? Share a moment, date idea, or story...", - color = Color(0xFFA0A0A8), + Spacer(modifier = Modifier.height(16.dp)) + + // Text Area + TextField( + value = textState, + onValueChange = { if (it.length <= maxChar) textState = it }, + enabled = !isPosting, + textStyle = TextStyle( fontSize = 20.sp, - lineHeight = 28.sp + lineHeight = 28.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF1E1E1E), + letterSpacing = 0.2.sp + ), + placeholder = { + Text( + "What's your vibe today? Share a moment, date idea, or story...", + color = Color(0xFFA0A0A8), + fontSize = 20.sp, + lineHeight = 28.sp + ) + }, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = Color(0xFFE91E63) ) - }, - modifier = Modifier.fillMaxWidth(), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color(0xFFE91E63) ) - ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Spacer(modifier = Modifier.height(16.dp)) + // Horizontal Scrollable Media Row + if (selectedMediaList.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + itemsIndexed(selectedMediaList) { index, item -> + Card( + shape = RoundedCornerShape(20.dp), + modifier = Modifier + .width(280.dp) + .height(220.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + when (item) { + is MediaContentItem.Photo -> { + Image( + painter = rememberAsyncImagePainter(item.uri), + contentDescription = "Photo Preview", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + is MediaContentItem.Video -> { + VideoPlayerItem(videoUri = item.uri) + } + } - // Horizontal Scrollable Media Row (Contains Photos and Videos) - if (selectedMediaList.isNotEmpty()) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - itemsIndexed(selectedMediaList) { index, item -> - Card( - shape = RoundedCornerShape(20.dp), - modifier = Modifier - .width(280.dp) - .height(220.dp) - ) { - Box(modifier = Modifier.fillMaxSize()) { - when (item) { - is MediaContentItem.Photo -> { - Image( - painter = rememberAsyncImagePainter(item.uri), - contentDescription = "Photo Preview", - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(10.dp) + ) { + OverlayIconButton( + icon = Icons.Default.Close, + onClick = { if (!isPosting) selectedMediaList.removeAt(index) } ) } - is MediaContentItem.Video -> { - VideoPlayerItem(videoUri = item.uri) - } - } - // Delete Card Button - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(10.dp) - ) { - OverlayIconButton( - icon = Icons.Default.Close, - onClick = { selectedMediaList.removeAt(index) } - ) - } - - // Media Type Badge Overlay (Top Left) - Surface( - color = DarkOverlay, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .align(Alignment.TopStart) - .padding(10.dp) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically + Surface( + color = DarkOverlay, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp) ) { - Icon( - imageVector = if (item is MediaContentItem.Video) Icons.Default.Videocam else Icons.Default.Image, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = if (item is MediaContentItem.Video) "Video" else "Photo ${index + 1}", - color = Color.White, - fontSize = 11.sp - ) + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (item is MediaContentItem.Video) Icons.Default.Videocam else Icons.Default.Image, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (item is MediaContentItem.Video) "Video" else "Photo ${index + 1}", + color = Color.White, + fontSize = 11.sp + ) + } } } } } } + + Spacer(modifier = Modifier.height(16.dp)) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) } - // Location Tag Bar - - - Spacer(modifier = Modifier.height(20.dp)) - - - Spacer(modifier = Modifier.height(10.dp)) - - Spacer(modifier = Modifier.height(24.dp)) + // LOADING BAR: runs left to right along the top while posting + if (isPosting) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .align(Alignment.TopCenter), + color = Color(0xFFC2185B), + trackColor = Color(0xFFF3D5E3) + ) + } } - // Camera / Gallery Choice Bottom Sheet Dialog + // Camera / Gallery Choice Bottom Sheet if (showImagePickerDialog) { ModalBottomSheet( onDismissRequest = { showImagePickerDialog = false }, @@ -620,7 +590,6 @@ fun CreateVibePostScreen( modifier = Modifier.padding(bottom = 16.dp) ) - // Pick from Gallery (Photo + Video combined option) Row( modifier = Modifier .fillMaxWidth() @@ -643,7 +612,6 @@ fun CreateVibePostScreen( Text(text = "Choose Photos & Videos from Gallery", fontSize = 16.sp) } - // Take Photo Row( modifier = Modifier .fillMaxWidth() @@ -666,7 +634,6 @@ fun CreateVibePostScreen( Text(text = "Take Photo", fontSize = 16.sp) } - // Record Video Row( modifier = Modifier .fillMaxWidth() @@ -696,9 +663,7 @@ fun CreateVibePostScreen( if (showFeelingSheet) { FeelingPickerBottomSheet( onDismiss = { showFeelingSheet = false }, - onFeelingSelected = { feeling -> - selectedFeeling = feeling - } + onFeelingSelected = { feeling -> selectedFeeling = feeling } ) } } @@ -716,9 +681,7 @@ fun VideoPlayerItem(videoUri: Uri) { } DisposableEffect(Unit) { - onDispose { - exoPlayer.release() - } + onDispose { exoPlayer.release() } } AndroidView( @@ -775,13 +738,10 @@ fun VibeOptionChip( modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(6.dp)) - } } } - - @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeelingPickerBottomSheet( @@ -824,10 +784,7 @@ fun FeelingPickerBottomSheet( .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = feeling.emoji, - fontSize = 20.sp - ) + Text(text = feeling.emoji, fontSize = 20.sp) Spacer(modifier = Modifier.width(8.dp)) Text( text = feeling.name, @@ -842,10 +799,11 @@ fun FeelingPickerBottomSheet( } } } + @Preview(showBackground = true, showSystemUi = true, backgroundColor = 0xFFFFFFFF) @Composable fun CreateVibePostScreenPreview() { DattingAPPTheme { CreateVibePostScreen() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt b/app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt index 4dde083..663e7a8 100644 --- a/app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt +++ b/app/src/main/java/com/example/dattingapp/homes/HomeActivity.kt @@ -350,7 +350,9 @@ fun HomeScreen() { // CREATE POST // ========================================= 2 -> { - CreateVibePostScreen() + CreateVibePostScreen( + onShareClick = { selectedItem = 1 } // 1 = Explore tab + ) } // ========================================= // CHAT diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt index 567d9c0..70e2737 100644 --- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt +++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeApiService.kt @@ -50,12 +50,14 @@ interface HomeApiService { @Path("id") postId: Int ): Response - @POST("/api/v1/explore/posts") + @POST("api/v1/explore/posts") suspend fun createPost( - @Body request: CreatePostRequest + @Body body: CreatePostRequest ): Response - + // POST Upload Post Media + // https://melove.youthome.com.cn/api/v1/explore/posts/{post_id}/media?position=0 + // form-data field name: "media" (as in your second screenshot) @Multipart @POST("api/v1/explore/posts/{post_id}/media") suspend fun uploadPostMedia( diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt index afa0060..27d2fba 100644 --- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt +++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeDataClass.kt @@ -179,58 +179,64 @@ data class PostAuthor( ) -//this is for create post +// ---------------- Create Post ---------------- + data class Location( - val name: String?, - val latitude: Double?, - val longitude: Double? + @SerializedName("name") val name: String?, + @SerializedName("latitude") val latitude: Double?, + @SerializedName("longitude") val longitude: Double? ) -data class CreatePostAuthor( - val id: Int, - val full_name: String, - val city: String, - val country: String, - val compatibility_percentage: Int +// Request body — matches your first screenshot exactly +data class CreatePostRequest( + @SerializedName("content") val content: String, + @SerializedName("post_type") val post_type: String, + @SerializedName("content_format") val content_format: String, + @SerializedName("feeling") val feeling: String?, + @SerializedName("location") val location: Location? ) -// Represents the request payload structure (Top Image) -data class CreatePostRequest( - val content: String, - val post_type: String, - val content_format: String, - val feeling: String?, - val location: Location? +data class CreatePostAuthor( + @SerializedName("id") val id: Int, + @SerializedName("full_name") val full_name: String?, + @SerializedName("profile_image_url") val profile_image_url: String?, + @SerializedName("city") val city: String?, + @SerializedName("country") val country: String?, // server returns null here! + @SerializedName("compatibility_percentage") val compatibility_percentage: Int? ) -// Represents the full post response (Bottom Image) +// Response — matches your first screenshot's response body data class CreatePostResponse( - val id: Int, - val content: String, - val post_type: String, - val content_format: String, - val feeling: String?, - val location: Location?, - val author: CreatePostAuthor, - val like_count: Int, - val comment_count: Int, - val liked_by_me: Boolean, - val media: List, // Change to specific model if media items have structure - val created_at: String + @SerializedName("id") val id: Int, + @SerializedName("content") val content: String?, + @SerializedName("post_type") val post_type: String?, + @SerializedName("content_format") val content_format: String?, + @SerializedName("feeling") val feeling: String?, + @SerializedName("location") val location: Location?, + @SerializedName("author") val author: CreatePostAuthor?, + @SerializedName("like_count") val like_count: Int?, + @SerializedName("comment_count") val comment_count: Int?, + @SerializedName("liked_by_me") val liked_by_me: Boolean?, + @SerializedName("liked_by_users") val liked_by_users: List?, + @SerializedName("media") val media: List?, + @SerializedName("created_at") val created_at: String? ) +// ---------------- Upload Post Media ---------------- + +// Response — matches your second screenshot's response body +data class PostMediaResponse( + @SerializedName("id") val id: Int, + @SerializedName("media_type") val media_type: String?, + @SerializedName("mime_type") val mime_type: String?, + @SerializedName("size_bytes") val size_bytes: Long?, + @SerializedName("position") val position: Int?, + @SerializedName("url") val url: String? +) data class DeletePostResponse( val message: String ) -data class PostMediaResponse( - val id: Int, - val media_type: String, - val mime_type: String, - val size_bytes: Int, - val position: Int, - val url: String -) data class PostInteraction( val likedByMe: Boolean, diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt index ba2753d..c250486 100644 --- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt +++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeNetwokModule.kt @@ -8,6 +8,11 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit + + +object AppLog { + const val TAG = "PostFlow" // ← the ONLY tag you filter in Logcat +} object HomeNetworkModule { private const val BASE_URL = "https://melove.youthome.com.cn/" diff --git a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt index e78a31e..d441699 100644 --- a/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt +++ b/app/src/main/java/com/example/dattingapp/homes/networkandapi/HomeViewModelClass.kt @@ -312,13 +312,11 @@ class PostViewModel : ViewModel() { } -//this is for createpost viwmodel - sealed interface CreatePostUiState { object Idle : CreatePostUiState object Loading : CreatePostUiState - data class Success(val postData: CreatePostResponse) : CreatePostUiState // Changed to List + data class Success(val postData: CreatePostResponse) : CreatePostUiState data class Error(val message: String) : CreatePostUiState } @@ -336,50 +334,91 @@ class CreatePostViewModel : ViewModel() { _cretePostUiState.value = CreatePostUiState.Loading if (TokenManager.getInstance(appContext).getAccessToken().isNullOrBlank()) { - Log.e("MeetApi", "No access token in user_tokens preferences") - _cretePostUiState.value = CreatePostUiState.Error("No access token found. Please log in again.") + Log.e(AppLog.TAG, "No access token found") + _cretePostUiState.value = + CreatePostUiState.Error("No access token found. Please log in again.") return@launch } try { - // Returns CreatePostResponse directly - val createdPost = HomeNetworkModule + val safeRequest = postInfo.copy( + content = postInfo.content.trim().ifBlank { " " }, + content_format = "plain", + location = postInfo.location?.let { + if (it.name.isNullOrBlank()) it.copy(name = "Current Location") else it + } ?: Location(name = "Current Location", latitude = null, longitude = null) + ) + + Log.d(AppLog.TAG, + "Sending JSON: ${com.google.gson.GsonBuilder().serializeNulls().create().toJson(safeRequest)}") + + val response = HomeNetworkModule .getApiService(appContext) - .createPost(postInfo) + .createPost(safeRequest) - Log.d("CreatePostDebug", "Response Code: ${createdPost.code()}") - if (createdPost.isSuccessful && createdPost.body() != null) { - _cretePostUiState.value = CreatePostUiState.Success(createdPost.body()!!) // Use .body() here! + Log.d(AppLog.TAG, "Create post response code: ${response.code()}") + val body = response.body() + if (response.isSuccessful && body != null) { + Log.d(AppLog.TAG, "Post created, id = ${body.id}") + _cretePostUiState.value = CreatePostUiState.Success(body) } else { - _cretePostUiState.value = CreatePostUiState.Error("No post data received from the server.") + val err = response.errorBody()?.string() + Log.e(AppLog.TAG, "Create post FAILED HTTP ${response.code()}: $err") + val message = when (response.code()) { + 401 -> "Authentication failed. Please log in again." + 403 -> "The server refused access." + 422 -> "Invalid post: ${parse422Message(err)}" + else -> "HTTP ${response.code()}: ${err ?: "no details"}" + } + _cretePostUiState.value = CreatePostUiState.Error(message) } - } catch (e: HttpException) { - // Handle HTTP errors via HttpException automatically thrown by Retrofit - val message = when (e.code()) { - 401 -> "Authentication failed. Please log in again." - 403 -> "The server refused access to this profile." - 404 -> "User profile was not found." - else -> "Request failed: HTTP ${e.code()}." - } - _cretePostUiState.value = CreatePostUiState.Error(message) + Log.e(AppLog.TAG, "Create post HttpException: ${e.code()}", e) + _cretePostUiState.value = + CreatePostUiState.Error("Request failed: HTTP ${e.code()}.") } catch (e: Exception) { - Log.e("MeetApi", "Unexpected request failure", e) - _cretePostUiState.value = CreatePostUiState.Error("Request failed: ${e.javaClass.simpleName}") + Log.e(AppLog.TAG, "Create post unexpected failure", e) + _cretePostUiState.value = + CreatePostUiState.Error("Request failed: ${e.javaClass.simpleName}") + } + } + } + + private fun parse422Message(raw: String?): String { + if (raw.isNullOrBlank()) return "validation failed" + return try { + val json = org.json.JSONObject(raw) + when { + json.has("detail") -> { + val detail = json.get("detail") + if (detail is org.json.JSONArray && detail.length() > 0) { + val first = detail.getJSONObject(0) + val field = first.optJSONArray("loc") + ?.let { it.getString(it.length() - 1) } ?: "field" + "$field: ${first.optString("msg")}" + } else detail.toString() + } + json.has("message") -> json.getString("message") + else -> raw } + } catch (e: Exception) { + raw } } + + fun reset() { + _cretePostUiState.value = CreatePostUiState.Idle + } } sealed interface PostMediaUiState { object Idle : PostMediaUiState object Loading : PostMediaUiState - data class Success(val postData: PostMediaResponse) : PostMediaUiState // Changed to List + data class Success(val postData: PostMediaResponse) : PostMediaUiState data class Error(val message: String) : PostMediaUiState } - class PostMediaViewModel : ViewModel() { private val _postMediaUiState = MutableStateFlow(PostMediaUiState.Idle) @@ -394,13 +433,13 @@ class PostMediaViewModel : ViewModel() { _postMediaUiState.value = PostMediaUiState.Loading if (TokenManager.getInstance(appContext).getAccessToken().isNullOrBlank()) { - Log.e("MeetApi", "No access token in user_tokens preferences") - _postMediaUiState.value = PostMediaUiState.Error("No access token found. Please log in again.") + Log.e(AppLog.TAG, "No access token in user_tokens preferences") + _postMediaUiState.value = + PostMediaUiState.Error("No access token found. Please log in again.") return@launch } try { - // Fix: Use return@launch instead of return false inside viewModelScope.launch val multipartPart = uriToMultipartPart(appContext, uri) if (multipartPart == null) { _postMediaUiState.value = PostMediaUiState.Error("Failed to process media file.") @@ -411,14 +450,18 @@ class PostMediaViewModel : ViewModel() { .getApiService(appContext) .uploadPostMedia(postId = postId, position = position, mediaFile = multipartPart) - Log.d("CreatePostDebug", "Response Code: ${response.code()}") + Log.d(AppLog.TAG, "Upload media response code: ${response.code()}") if (response.isSuccessful && response.body() != null) { _postMediaUiState.value = PostMediaUiState.Success(response.body()!!) } else { - _postMediaUiState.value = PostMediaUiState.Error("No post data received from the server.") + val err = response.errorBody()?.string() + Log.e(AppLog.TAG, "Upload media FAILED HTTP ${response.code()}: $err") + _postMediaUiState.value = + PostMediaUiState.Error("Upload failed (${response.code()}): ${err ?: "no details"}") } } catch (e: HttpException) { + Log.e(AppLog.TAG, "Upload media HttpException: ${e.code()}", e) val message = when (e.code()) { 401 -> "Authentication failed. Please log in again." 403 -> "The server refused access to this profile." @@ -427,14 +470,43 @@ class PostMediaViewModel : ViewModel() { } _postMediaUiState.value = PostMediaUiState.Error(message) } catch (e: Exception) { - Log.e("MeetApi", "Unexpected request failure", e) - _postMediaUiState.value = PostMediaUiState.Error("Request failed: ${e.javaClass.simpleName}") + Log.e(AppLog.TAG, "Upload media unexpected failure", e) + _postMediaUiState.value = + PostMediaUiState.Error("Request failed: ${e.javaClass.simpleName}") } } } + suspend fun uploadMedia(context: Context, postId: Int, position: Int, uri: Uri): String? { + val appContext = context.applicationContext + return try { + val part = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + uriToMultipartPart(appContext, uri) + } ?: run { + Log.e(AppLog.TAG, "Upload $position: could not read the file ($uri)") + return "Could not read the file" + } + + val response = HomeNetworkModule + .getApiService(appContext) + .uploadPostMedia(postId = postId, position = position, mediaFile = part) + + if (response.isSuccessful) { + Log.d(AppLog.TAG, "Upload $position OK (${response.code()})") + null + } else { + val raw = try { response.errorBody()?.string() } catch (e: Exception) { null } + Log.e(AppLog.TAG, "Upload $position FAILED code=${response.code()} body=$raw url=${response.raw().request.url}") + "Upload failed (${response.code()}): ${raw ?: "no details"}" + } + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e + } catch (e: Exception) { + Log.e(AppLog.TAG, "Upload $position exception", e) + "${e.javaClass.simpleName}: ${e.message ?: "no details"}" + } + } - // Helper to convert Uri to MultipartBody.Part fun uriToMultipartPart( context: Context, uri: Uri, @@ -444,7 +516,6 @@ class PostMediaViewModel : ViewModel() { val contentResolver = context.contentResolver val mimeType = contentResolver.getType(uri) ?: "image/*" - // Create a temp file in cache directory val inputStream = contentResolver.openInputStream(uri) ?: return null val tempFile = File.createTempFile("upload_", getExtensionFromMimeType(mimeType), context.cacheDir) @@ -457,7 +528,7 @@ class PostMediaViewModel : ViewModel() { val requestBody = tempFile.asRequestBody(mimeType.toMediaTypeOrNull()) return MultipartBody.Part.createFormData(paramName, tempFile.name, requestBody) } catch (e: kotlin.Exception) { - e.printStackTrace() + Log.e(AppLog.TAG, "Failed to convert Uri to multipart: $uri", e) return null } }