@@ -243,11 +243,16 @@ fun TimelineScreen(
|
||||
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 selectedHashtag by remember { mutableStateOf<String?>(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 },
|
||||
|
||||
@@ -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<String>? = null,
|
||||
@Field("poll[options][]") pollOptions: List<String>? = null,
|
||||
@Field("poll[expires_in]") pollExpiresIn: Int? = null,
|
||||
@Field("poll[multiple]") pollMultiple: Boolean? = null
|
||||
): Response<Status>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<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) {
|
||||
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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
340
app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeScreen.kt
Archivo normal
340
app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeScreen.kt
Archivo normal
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Referencia en una nueva incidencia
Block a user