fix send button

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-01-25 16:57:06 +01:00
padre 8dc1ea12d9
commit 088e020b39
Se han modificado 6 ficheros con 495 adiciones y 12 borrados

Ver fichero

@@ -243,11 +243,16 @@ fun TimelineScreen(
var replyingToStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) } var replyingToStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) }
var selectedStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) } var selectedStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) }
var selectedHashtag by remember { mutableStateOf<String?>(null) } var selectedHashtag by remember { mutableStateOf<String?>(null) }
var showComposeScreen by remember { mutableStateOf(false) }
// Remember scroll state to preserve position when navigating // Remember scroll state to preserve position when navigating
val timelineScrollState = rememberLazyListState() val timelineScrollState = rememberLazyListState()
// Handle back button for different states // Handle back button for different states
BackHandler(enabled = showComposeScreen) {
showComposeScreen = false
}
BackHandler(enabled = replyingToStatus != null) { BackHandler(enabled = replyingToStatus != null) {
replyingToStatus = null replyingToStatus = null
} }
@@ -271,6 +276,10 @@ fun TimelineScreen(
replyingToStatus = null replyingToStatus = null
viewModel.resetInteractionState() viewModel.resetInteractionState()
} }
is InteractionState.PostCreated -> {
showComposeScreen = false
viewModel.resetInteractionState()
}
is InteractionState.Success -> { is InteractionState.Success -> {
// Auto-reset after short delay // Auto-reset after short delay
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
@@ -376,10 +385,40 @@ fun TimelineScreen(
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
) )
) )
},
floatingActionButton = {
// Show FAB only on timeline (not on detail, reply, etc.) and when authenticated
if (!showNotifications && selectedStatus == null && replyingToStatus == null &&
selectedHashtag == null && !showComposeScreen && userSession != null) {
FloatingActionButton(
onClick = { showComposeScreen = true },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.Edit, contentDescription = "New post")
}
}
} }
) { paddingValues -> ) { paddingValues ->
// Show compose reply screen if replying // Show compose screen if creating new post
if (replyingToStatus != null) { if (showComposeScreen) {
com.manalejandro.myactivitypub.ui.screens.ComposeScreen(
onBack = { showComposeScreen = false },
onPost = { content, cw, visibility, mediaIds, pollOptions, pollExpiresIn, pollMultiple ->
viewModel.postNewStatus(
content = content,
contentWarning = cw,
visibility = visibility,
mediaIds = mediaIds,
pollOptions = pollOptions,
pollExpiresIn = pollExpiresIn,
pollMultiple = pollMultiple
)
},
isPosting = interactionState is InteractionState.Processing
)
} else if (replyingToStatus != null) {
// Show compose reply screen if replying
com.manalejandro.myactivitypub.ui.screens.ComposeReplyScreen( com.manalejandro.myactivitypub.ui.screens.ComposeReplyScreen(
status = replyingToStatus!!, status = replyingToStatus!!,
onBack = { replyingToStatus = null }, onBack = { replyingToStatus = null },

Ver fichero

@@ -127,7 +127,13 @@ interface MastodonApiService {
suspend fun postStatus( suspend fun postStatus(
@Field("status") status: String, @Field("status") status: String,
@Field("in_reply_to_id") inReplyToId: String? = null, @Field("in_reply_to_id") inReplyToId: String? = null,
@Field("visibility") visibility: String = "public" @Field("visibility") visibility: String = "public",
@Field("spoiler_text") spoilerText: String? = null,
@Field("sensitive") sensitive: Boolean? = null,
@Field("media_ids[]") mediaIds: List<String>? = null,
@Field("poll[options][]") pollOptions: List<String>? = null,
@Field("poll[expires_in]") pollExpiresIn: Int? = null,
@Field("poll[multiple]") pollMultiple: Boolean? = null
): Response<Status> ): Response<Status>
/** /**

Ver fichero

@@ -142,10 +142,30 @@ class MastodonRepository(private val apiService: MastodonApiService) {
/** /**
* Post a new status or reply * Post a new status or reply
*/ */
suspend fun postStatus(content: String, inReplyToId: String? = null): Result<Status> { suspend fun postStatus(
content: String,
inReplyToId: String? = null,
visibility: String = "public",
spoilerText: String? = null,
sensitive: Boolean? = null,
mediaIds: List<String>? = null,
pollOptions: List<String>? = null,
pollExpiresIn: Int? = null,
pollMultiple: Boolean? = null
): Result<Status> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val response = apiService.postStatus(content, inReplyToId) val response = apiService.postStatus(
status = content,
inReplyToId = inReplyToId,
visibility = visibility,
spoilerText = spoilerText,
sensitive = sensitive,
mediaIds = mediaIds,
pollOptions = pollOptions,
pollExpiresIn = pollExpiresIn,
pollMultiple = pollMultiple
)
if (response.isSuccessful && response.body() != null) { if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!) Result.success(response.body()!!)
} else { } else {

Ver fichero

@@ -80,7 +80,8 @@ fun ComposeReplyScreen(
} }
}, },
actions = { actions = {
Button( // Send button
IconButton(
onClick = { onClick = {
if (replyText.isNotBlank() && replyText.length <= 500) { if (replyText.isNotBlank() && replyText.length <= 500) {
onReply( onReply(
@@ -90,22 +91,59 @@ fun ComposeReplyScreen(
) )
} }
}, },
enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting, enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting
modifier = Modifier.padding(end = 8.dp)
) { ) {
if (isPosting) { if (isPosting) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(20.dp), modifier = Modifier.size(24.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp
color = MaterialTheme.colorScheme.onPrimary
) )
} else { } else {
Text("Post") Icon(
imageVector = Icons.Default.Send,
contentDescription = "Send reply",
tint = if (replyText.isNotBlank() && replyText.length <= 500) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
}
)
} }
} }
} }
) )
}, },
floatingActionButton = {
// Large visible send button
FloatingActionButton(
onClick = {
if (replyText.isNotBlank() && replyText.length <= 500) {
onReply(
replyText,
if (showCW && contentWarning.isNotBlank()) contentWarning else null,
visibility
)
}
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(bottom = 80.dp, end = 16.dp)
) {
if (isPosting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.Send,
contentDescription = "Send reply",
modifier = Modifier.size(28.dp)
)
}
}
},
bottomBar = { bottomBar = {
ComposeBottomBar( ComposeBottomBar(
onImageClick = { onImageClick = {

Ver fichero

@@ -0,0 +1,340 @@
package com.manalejandro.myactivitypub.ui.screens
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.manalejandro.myactivitypub.ui.components.*
/**
* Full-screen compose interface for creating new posts
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComposeScreen(
onBack: () -> Unit,
onPost: (content: String, contentWarning: String?, visibility: String, mediaIds: List<String>?, pollOptions: List<String>?, pollExpiresIn: Int?, pollMultiple: Boolean?) -> Unit,
isPosting: Boolean = false
) {
var postText by remember { mutableStateOf("") }
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
var showCW by remember { mutableStateOf(false) }
var contentWarning by remember { mutableStateOf("") }
var visibility by remember { mutableStateOf("public") }
var showVisibilityMenu by remember { mutableStateOf(false) }
// Media, emoji, and poll state
var selectedMedia by remember { mutableStateOf<List<Uri>>(emptyList()) }
var showEmojiPicker by remember { mutableStateOf(false) }
var showPollCreator by remember { mutableStateOf(false) }
var poll by remember { mutableStateOf<PollData?>(null) }
var mediaLauncherTrigger by remember { mutableStateOf<(() -> Unit)?>(null) }
val scrollState = rememberScrollState()
// Show emoji picker dialog
if (showEmojiPicker) {
EmojiPickerDialog(
onDismiss = { showEmojiPicker = false },
onEmojiSelected = { emoji ->
// Insert emoji at cursor position
val currentText = textFieldValue.text
val selection = textFieldValue.selection
val newText = currentText.substring(0, selection.start) +
emoji +
currentText.substring(selection.end)
textFieldValue = TextFieldValue(
text = newText,
selection = TextRange(selection.start + emoji.length)
)
postText = newText
}
)
}
// Show poll creator dialog
if (showPollCreator) {
PollCreatorDialog(
onDismiss = { showPollCreator = false },
onPollCreated = { newPoll ->
poll = newPoll
showPollCreator = false
},
initialPoll = poll
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("New Post") },
navigationIcon = {
IconButton(onClick = onBack, enabled = !isPosting) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
// Send button
IconButton(
onClick = {
if (postText.isNotBlank() && postText.length <= 500) {
onPost(
postText,
if (showCW && contentWarning.isNotBlank()) contentWarning else null,
visibility,
null, // mediaIds - to be implemented
poll?.options,
poll?.expiresIn,
poll?.multiple
)
}
},
enabled = postText.isNotBlank() && postText.length <= 500 && !isPosting
) {
if (isPosting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.Send,
contentDescription = "Send post",
tint = if (postText.isNotBlank() && postText.length <= 500) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
}
)
}
}
}
)
},
floatingActionButton = {
// Large visible send button
FloatingActionButton(
onClick = {
if (postText.isNotBlank() && postText.length <= 500) {
onPost(
postText,
if (showCW && contentWarning.isNotBlank()) contentWarning else null,
visibility,
null, // mediaIds
poll?.options,
poll?.expiresIn,
poll?.multiple
)
}
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(bottom = 80.dp, end = 16.dp)
) {
if (isPosting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.Send,
contentDescription = "Send post",
modifier = Modifier.size(28.dp)
)
}
}
},
bottomBar = {
ComposeBottomBar(
onImageClick = {
mediaLauncherTrigger?.invoke()
},
onEmojiClick = { showEmojiPicker = true },
onPollClick = {
if (poll == null) {
showPollCreator = true
}
},
onCWClick = { showCW = !showCW },
onVisibilityClick = { showVisibilityMenu = true },
visibility = visibility,
showCW = showCW,
enabled = !isPosting
)
}
) { padding ->
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(scrollState)
) {
// Content Warning field
if (showCW) {
OutlinedTextField(
value = contentWarning,
onValueChange = { contentWarning = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Content warning (optional)") },
trailingIcon = {
IconButton(onClick = {
showCW = false
contentWarning = ""
}) {
Icon(Icons.Default.Close, "Remove content warning")
}
},
enabled = !isPosting
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
// Media picker
MediaPicker(
selectedMedia = selectedMedia,
onMediaSelected = { uris ->
selectedMedia = (selectedMedia + uris).take(4)
},
onMediaRemoved = { uri ->
selectedMedia = selectedMedia.filter { it != uri }
},
onLauncherReady = { trigger ->
mediaLauncherTrigger = trigger
}
)
// Poll preview
poll?.let { currentPoll ->
PollPreviewCard(
poll = currentPoll,
onRemove = { poll = null },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
// Main text field
OutlinedTextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
postText = it.text
},
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp)
.heightIn(min = 300.dp),
placeholder = { Text("What's on your mind?") },
enabled = !isPosting
)
Spacer(modifier = Modifier.height(16.dp))
// Character counter and visibility
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${postText.length}/500",
style = MaterialTheme.typography.bodySmall,
color = if (postText.length > 500)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
when (visibility) {
"public" -> Icons.Default.Public
"unlisted" -> Icons.Default.Lock
"private" -> Icons.Default.LockOpen
"direct" -> Icons.Default.Mail
else -> Icons.Default.Public
},
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = visibility.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (postText.length > 500) {
Text(
text = "⚠️ Text is too long (${postText.length}/500)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// Visibility menu
DropdownMenu(
expanded = showVisibilityMenu,
onDismissRequest = { showVisibilityMenu = false }
) {
DropdownMenuItem(
text = { Text("Public") },
leadingIcon = { Icon(Icons.Default.Public, null) },
onClick = {
visibility = "public"
showVisibilityMenu = false
}
)
DropdownMenuItem(
text = { Text("Unlisted") },
leadingIcon = { Icon(Icons.Default.Lock, null) },
onClick = {
visibility = "unlisted"
showVisibilityMenu = false
}
)
DropdownMenuItem(
text = { Text("Followers only") },
leadingIcon = { Icon(Icons.Default.LockOpen, null) },
onClick = {
visibility = "private"
showVisibilityMenu = false
}
)
DropdownMenuItem(
text = { Text("Direct") },
leadingIcon = { Icon(Icons.Default.Mail, null) },
onClick = {
visibility = "direct"
showVisibilityMenu = false
}
)
}
}
}
}

Ver fichero

@@ -248,6 +248,45 @@ class TimelineViewModel(private val repository: MastodonRepository) : ViewModel(
} }
} }
/**
* Post a new status
*/
fun postNewStatus(
content: String,
contentWarning: String? = null,
visibility: String = "public",
mediaIds: List<String>? = null,
pollOptions: List<String>? = null,
pollExpiresIn: Int? = null,
pollMultiple: Boolean? = null
) {
viewModelScope.launch {
_interactionState.value = InteractionState.Processing("")
repository.postStatus(
content = content,
inReplyToId = null,
visibility = visibility,
spoilerText = contentWarning,
sensitive = false,
mediaIds = mediaIds,
pollOptions = pollOptions,
pollExpiresIn = pollExpiresIn,
pollMultiple = pollMultiple
).fold(
onSuccess = { newStatus ->
// Add new status to the beginning of the timeline
currentStatuses.add(0, newStatus)
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
_interactionState.value = InteractionState.PostCreated
},
onFailure = { error ->
_interactionState.value = InteractionState.Error(error.message ?: "Failed to post")
}
)
}
}
/** /**
* Update a status in the current timeline * Update a status in the current timeline
*/ */
@@ -299,6 +338,7 @@ sealed class InteractionState {
data class Processing(val statusId: String) : InteractionState() data class Processing(val statusId: String) : InteractionState()
object Success : InteractionState() object Success : InteractionState()
object ReplyPosted : InteractionState() object ReplyPosted : InteractionState()
object PostCreated : InteractionState()
data class Error(val message: String) : InteractionState() data class Error(val message: String) : InteractionState()
} }