From 088e020b392cbb95d602f2b66a708e2f20f1714b Mon Sep 17 00:00:00 2001 From: ale Date: Sun, 25 Jan 2026 16:57:06 +0100 Subject: [PATCH] fix send button Signed-off-by: ale --- .../myactivitypub/MainActivity.kt | 43 ++- .../data/api/MastodonApiService.kt | 8 +- .../data/repository/MastodonRepository.kt | 24 +- .../ui/screens/ComposeReplyScreen.kt | 52 ++- .../myactivitypub/ui/screens/ComposeScreen.kt | 340 ++++++++++++++++++ .../ui/viewmodel/TimelineViewModel.kt | 40 +++ 6 files changed, 495 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeScreen.kt diff --git a/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt b/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt index 954bc21..8ed5989 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt @@ -243,11 +243,16 @@ fun TimelineScreen( var replyingToStatus by remember { mutableStateOf(null) } var selectedStatus by remember { mutableStateOf(null) } var selectedHashtag by remember { mutableStateOf(null) } + var showComposeScreen by remember { mutableStateOf(false) } // Remember scroll state to preserve position when navigating val timelineScrollState = rememberLazyListState() // Handle back button for different states + BackHandler(enabled = showComposeScreen) { + showComposeScreen = false + } + BackHandler(enabled = replyingToStatus != null) { replyingToStatus = null } @@ -271,6 +276,10 @@ fun TimelineScreen( replyingToStatus = null viewModel.resetInteractionState() } + is InteractionState.PostCreated -> { + showComposeScreen = false + viewModel.resetInteractionState() + } is InteractionState.Success -> { // Auto-reset after short delay kotlinx.coroutines.delay(500) @@ -376,10 +385,40 @@ fun TimelineScreen( 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 -> - // Show compose reply screen if replying - if (replyingToStatus != null) { + // Show compose screen if creating new post + 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( status = replyingToStatus!!, onBack = { replyingToStatus = null }, diff --git a/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt b/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt index 3040311..d4a3f7e 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt @@ -127,7 +127,13 @@ interface MastodonApiService { suspend fun postStatus( @Field("status") status: String, @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? = null, + @Field("poll[options][]") pollOptions: List? = null, + @Field("poll[expires_in]") pollExpiresIn: Int? = null, + @Field("poll[multiple]") pollMultiple: Boolean? = null ): Response /** diff --git a/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt b/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt index 8840751..143ea62 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt @@ -142,10 +142,30 @@ class MastodonRepository(private val apiService: MastodonApiService) { /** * Post a new status or reply */ - suspend fun postStatus(content: String, inReplyToId: String? = null): Result { + suspend fun postStatus( + content: String, + inReplyToId: String? = null, + visibility: String = "public", + spoilerText: String? = null, + sensitive: Boolean? = null, + mediaIds: List? = null, + pollOptions: List? = null, + pollExpiresIn: Int? = null, + pollMultiple: Boolean? = null + ): Result { return withContext(Dispatchers.IO) { 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) { Result.success(response.body()!!) } else { diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt index 31adbeb..a32c203 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt @@ -80,7 +80,8 @@ fun ComposeReplyScreen( } }, actions = { - Button( + // Send button + IconButton( onClick = { if (replyText.isNotBlank() && replyText.length <= 500) { onReply( @@ -90,22 +91,59 @@ fun ComposeReplyScreen( ) } }, - enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting, - modifier = Modifier.padding(end = 8.dp) + enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting ) { if (isPosting) { CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp ) } 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 = { ComposeBottomBar( onImageClick = { diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeScreen.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeScreen.kt new file mode 100644 index 0000000..e0cd735 --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeScreen.kt @@ -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?, pollOptions: List?, 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>(emptyList()) } + var showEmojiPicker by remember { mutableStateOf(false) } + var showPollCreator by remember { mutableStateOf(false) } + var poll by remember { mutableStateOf(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 + } + ) + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/TimelineViewModel.kt index e5422bb..5655cb7 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/TimelineViewModel.kt @@ -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? = null, + pollOptions: List? = 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 */ @@ -299,6 +338,7 @@ sealed class InteractionState { data class Processing(val statusId: String) : InteractionState() object Success : InteractionState() object ReplyPosted : InteractionState() + object PostCreated : InteractionState() data class Error(val message: String) : InteractionState() }