some features

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-01-24 19:38:54 +01:00
padre 2d33461f40
commit ebc81e2bf0
Se han modificado 14 ficheros con 2013 adiciones y 35 borrados

Ver fichero

@@ -56,8 +56,9 @@ dependencies {
// Coil for image loading
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0")
// ViewModel
// Retrofit for API calls
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Coroutines
@@ -72,6 +73,10 @@ dependencies {
// Browser for OAuth
implementation("androidx.browser:browser:1.7.0")
// Media3 for video playback (replaces ExoPlayer)
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.media3:media3-ui:1.2.0")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

Ver fichero

@@ -242,15 +242,24 @@ fun TimelineScreen(
var showNotifications by remember { mutableStateOf(false) }
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) }
// Remember scroll state to preserve position when navigating
val timelineScrollState = rememberLazyListState()
// Handle back button for different states
BackHandler(enabled = replyingToStatus != null) {
replyingToStatus = null
}
BackHandler(enabled = selectedStatus != null) {
selectedStatus = null
}
BackHandler(enabled = selectedHashtag != null) {
selectedHashtag = null
}
BackHandler(enabled = showNotifications) {
showNotifications = false
}
@@ -271,17 +280,6 @@ fun TimelineScreen(
}
}
// Reply dialog
replyingToStatus?.let { status ->
com.manalejandro.myactivitypub.ui.components.ReplyDialog(
status = status,
onDismiss = { replyingToStatus = null },
onReply = { content ->
viewModel.postReply(content, status.id)
},
isPosting = interactionState is InteractionState.Processing
)
}
Scaffold(
topBar = {
@@ -380,8 +378,36 @@ fun TimelineScreen(
)
}
) { paddingValues ->
// Show status detail if selected
if (selectedStatus != null) {
// Show compose reply screen if replying
if (replyingToStatus != null) {
com.manalejandro.myactivitypub.ui.screens.ComposeReplyScreen(
status = replyingToStatus!!,
onBack = { replyingToStatus = null },
onReply = { content, cw, visibility ->
viewModel.postReply(content, replyingToStatus!!.id)
},
isPosting = interactionState is InteractionState.Processing
)
} else if (selectedHashtag != null) {
// Show hashtag timeline if selected
val hashtagApiService = remember(userSession) {
createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken)
}
val hashtagRepository = remember(hashtagApiService) {
MastodonRepository(hashtagApiService)
}
com.manalejandro.myactivitypub.ui.screens.HashtagTimelineScreen(
hashtag = selectedHashtag!!,
repository = hashtagRepository,
onBack = { selectedHashtag = null },
onStatusClick = { selectedStatus = it },
onReplyClick = { replyingToStatus = it },
onHashtagClick = { selectedHashtag = it },
isAuthenticated = userSession != null
)
} else if (selectedStatus != null) {
// Show status detail if selected
// Create repository for status detail
val detailApiService = remember(userSession) {
createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken)
@@ -395,6 +421,7 @@ fun TimelineScreen(
repository = detailRepository,
onBackClick = { selectedStatus = null },
onReplyClick = { replyingToStatus = it },
onHashtagClick = { selectedHashtag = it },
onStatusUpdated = { updatedStatus ->
viewModel.updateStatus(updatedStatus)
},
@@ -537,6 +564,7 @@ fun TimelineScreen(
onReplyClick = { replyingToStatus = it },
onBoostClick = { viewModel.toggleBoost(it) },
onFavoriteClick = { viewModel.toggleFavorite(it) },
onHashtagClick = { selectedHashtag = it },
isAuthenticated = userSession != null
)
}

Ver fichero

@@ -144,4 +144,17 @@ interface MastodonApiService {
*/
@GET("api/v1/statuses/{id}/context")
suspend fun getStatusContext(@Path("id") statusId: String): Response<StatusContext>
/**
* Get hashtag timeline
* @param hashtag Hashtag name (without #)
* @param limit Maximum number of statuses to return
* @param maxId Get statuses older than this ID (for pagination)
*/
@GET("api/v1/timelines/tag/{hashtag}")
suspend fun getHashtagTimeline(
@Path("hashtag") hashtag: String,
@Query("limit") limit: Int = 20,
@Query("max_id") maxId: String? = null
): Response<List<Status>>
}

Ver fichero

@@ -192,4 +192,22 @@ class MastodonRepository(private val apiService: MastodonApiService) {
}
}
}
/**
* Get hashtag timeline
*/
suspend fun getHashtagTimeline(hashtag: String, limit: Int = 20, maxId: String? = null): Result<List<Status>> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getHashtagTimeline(hashtag, limit, maxId)
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
}

Ver fichero

@@ -0,0 +1,235 @@
package com.manalejandro.myactivitypub.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
/**
* Emoji picker dialog for selecting emojis
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPickerDialog(
onDismiss: () -> Unit,
onEmojiSelected: (String) -> Unit
) {
val emojiCategories = remember {
mapOf(
"Smileys" to listOf(
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃",
"😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙",
"😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔"
),
"Gestures" to listOf(
"👋", "🤚", "🖐", "", "🖖", "👌", "🤏", "", "🤞", "🤟",
"🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "", "👍", "👎",
"", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏"
),
"Hearts" to listOf(
"", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
"", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "💌"
),
"Animals" to listOf(
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯",
"🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🐤", "🦆",
"🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋"
),
"Food" to listOf(
"🍎", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍈", "🍒", "🍑",
"🥭", "🍍", "🥥", "🥝", "🍅", "🥑", "🍆", "🥔", "🥕", "🌽",
"🌶", "🥒", "🥬", "🥦", "🍄", "🥜", "🌰", "🍞", "🥐", "🥖"
),
"Activity" to listOf(
"", "🏀", "🏈", "", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱",
"🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "", "🏹", "🎣", "🤿",
"🥊", "🥋", "🎽", "", "🥌", "🛷", "🎿", "", "🏂", "🏋"
),
"Objects" to listOf(
"", "📱", "💻", "", "🖥", "🖨", "🖱", "🖲", "🕹", "🗜",
"💾", "💿", "📀", "📼", "📷", "📸", "📹", "🎥", "📽", "🎞",
"📞", "", "📟", "📠", "📺", "📻", "🎙", "🎚", "🎛", ""
),
"Symbols" to listOf(
"", "💛", "💚", "💙", "💜", "🖤", "💔", "", "💕", "💞",
"", "", "🌟", "💫", "", "", "", "", "", "",
"", "", "🔴", "🟠", "🟡", "🟢", "🔵", "🟣", "", ""
)
)
}
var selectedCategory by remember { mutableStateOf("Smileys") }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Pick an emoji",
style = MaterialTheme.typography.titleLarge
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close")
}
}
HorizontalDivider()
// Category tabs
ScrollableTabRow(
selectedTabIndex = emojiCategories.keys.indexOf(selectedCategory),
modifier = Modifier.fillMaxWidth()
) {
emojiCategories.keys.forEach { category ->
Tab(
selected = selectedCategory == category,
onClick = { selectedCategory = category },
text = { Text(category) }
)
}
}
// Emoji grid
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(emojiCategories[selectedCategory] ?: emptyList()) { emoji ->
Text(
text = emoji,
fontSize = 32.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.size(48.dp)
.clickable {
onEmojiSelected(emoji)
onDismiss()
}
.padding(4.dp)
)
}
}
}
}
}
}
/**
* Emoji picker sheet for bottom sheet implementation
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPickerBottomSheet(
onDismiss: () -> Unit,
onEmojiSelected: (String) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = Modifier.fillMaxHeight(0.7f)
) {
EmojiPickerContent(
onEmojiSelected = { emoji ->
onEmojiSelected(emoji)
onDismiss()
}
)
}
}
@Composable
private fun EmojiPickerContent(
onEmojiSelected: (String) -> Unit
) {
val emojiCategories = remember {
mapOf(
"Smileys" to listOf(
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃",
"😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙",
"😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔"
),
"Gestures" to listOf(
"👋", "🤚", "🖐", "", "🖖", "👌", "🤏", "", "🤞", "🤟",
"🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "", "👍", "👎",
"", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏"
),
"Hearts" to listOf(
"", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
"", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "💌"
)
)
}
var selectedCategory by remember { mutableStateOf("Smileys") }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
// Category tabs
ScrollableTabRow(
selectedTabIndex = emojiCategories.keys.indexOf(selectedCategory),
modifier = Modifier.fillMaxWidth()
) {
emojiCategories.keys.forEach { category ->
Tab(
selected = selectedCategory == category,
onClick = { selectedCategory = category },
text = { Text(category) }
)
}
}
// Emoji grid
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(emojiCategories[selectedCategory] ?: emptyList()) { emoji ->
Text(
text = emoji,
fontSize = 32.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.size(48.dp)
.clickable { onEmojiSelected(emoji) }
.padding(4.dp)
)
}
}
}
}

Ver fichero

@@ -0,0 +1,229 @@
package com.manalejandro.myactivitypub.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
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.unit.dp
import com.manalejandro.myactivitypub.data.models.Status
/**
* Enhanced dialog for composing a reply to a status with full features
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EnhancedReplyDialog(
status: Status,
onDismiss: () -> Unit,
onReply: (content: String, contentWarning: String?, visibility: String) -> Unit,
isPosting: Boolean = false
) {
var replyText by remember { mutableStateOf("") }
var showCW by remember { mutableStateOf(false) }
var contentWarning by remember { mutableStateOf("") }
var visibility by remember { mutableStateOf("public") }
var showVisibilityMenu by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("Reply to @${status.account.acct}", modifier = Modifier.weight(1f))
// Visibility selector
Box {
IconButton(onClick = { showVisibilityMenu = true }) {
Icon(
when (visibility) {
"public" -> Icons.Default.Public
"unlisted" -> Icons.Default.Lock
"private" -> Icons.Default.LockOpen
"direct" -> Icons.Default.Mail
else -> Icons.Default.Public
},
contentDescription = "Visibility: $visibility"
)
}
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
}
)
}
}
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
// Show original toot excerpt
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = "Replying to:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = android.text.Html.fromHtml(
status.content,
android.text.Html.FROM_HTML_MODE_COMPACT
).toString().take(100) + if (status.content.length > 100) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Content Warning field (optional)
if (showCW) {
OutlinedTextField(
value = contentWarning,
onValueChange = { contentWarning = it },
label = { Text("Content Warning") },
placeholder = { Text("Sensitive content warning") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isPosting,
trailingIcon = {
IconButton(onClick = {
showCW = false
contentWarning = ""
}) {
Icon(Icons.Default.Close, "Remove CW")
}
}
)
Spacer(modifier = Modifier.height(8.dp))
}
// Reply text field
OutlinedTextField(
value = replyText,
onValueChange = { replyText = it },
label = { Text("Your reply") },
placeholder = { Text("Write your reply here...") },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
maxLines = 6,
enabled = !isPosting
)
Spacer(modifier = Modifier.height(8.dp))
// Action buttons bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
// Content Warning toggle
IconButton(
onClick = { showCW = !showCW },
enabled = !isPosting
) {
Icon(
Icons.Default.Warning,
contentDescription = "Content Warning",
tint = if (showCW) MaterialTheme.colorScheme.primary
else LocalContentColor.current
)
}
Spacer(modifier = Modifier.width(4.dp))
// Character count
Text(
text = "${replyText.length}/500",
style = MaterialTheme.typography.bodySmall,
color = if (replyText.length > 500) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
}
if (replyText.length > 500) {
Text(
text = "Text is too long",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
Button(
onClick = {
if (replyText.isNotBlank() && replyText.length <= 500) {
onReply(
replyText,
if (showCW && contentWarning.isNotBlank()) contentWarning else null,
visibility
)
}
},
enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting
) {
if (isPosting) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Reply")
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !isPosting) {
Text("Cancel")
}
}
)
}

Ver fichero

@@ -0,0 +1,137 @@
package com.manalejandro.myactivitypub.ui.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
/**
* Media picker and preview component for compose screen
* Returns a lambda to trigger the picker
*/
@Composable
fun MediaPicker(
selectedMedia: List<Uri>,
onMediaSelected: (List<Uri>) -> Unit,
onMediaRemoved: (Uri) -> Unit,
maxMedia: Int = 4,
onLauncherReady: (() -> Unit) -> Unit = {}
) {
val multiplePhotoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = maxMedia)
) { uris ->
if (uris.isNotEmpty()) {
onMediaSelected(uris)
}
}
// Expose the launcher trigger
LaunchedEffect(Unit) {
onLauncherReady {
multiplePhotoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
)
}
}
Column(modifier = Modifier.fillMaxWidth()) {
if (selectedMedia.isNotEmpty()) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(selectedMedia) { uri ->
MediaPreviewItem(
uri = uri,
onRemove = { onMediaRemoved(uri) }
)
}
// Add more button if under limit
if (selectedMedia.size < maxMedia) {
item {
AddMoreMediaButton(
onClick = {
multiplePhotoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
)
}
)
}
}
}
}
}
}
@Composable
private fun MediaPreviewItem(
uri: Uri,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.size(120.dp)
) {
Box {
AsyncImage(
model = uri,
contentDescription = "Selected media",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
IconButton(
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(24.dp)
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f),
shape = MaterialTheme.shapes.small
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.padding(2.dp)
)
}
}
}
}
}
@Composable
private fun AddMoreMediaButton(
onClick: () -> Unit
) {
Card(
modifier = Modifier.size(120.dp),
onClick = onClick
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"Add more",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}

Ver fichero

@@ -0,0 +1,231 @@
package com.manalejandro.myactivitypub.ui.components
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import com.manalejandro.myactivitypub.data.models.MediaAttachment
/**
* Media viewer dialog for viewing images, GIFs, and videos in full screen
*/
@Composable
fun MediaViewerDialog(
media: MediaAttachment,
onDismiss: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
when (media.type) {
"image", "gifv" -> {
// Display image or GIF
ImageViewer(
url = media.url,
description = media.description,
isGif = media.type == "gifv"
)
}
"video" -> {
// Display video with controls
VideoViewer(
url = media.url,
description = media.description
)
}
else -> {
// Fallback for unknown types
ImageViewer(
url = media.url,
description = media.description,
isGif = false
)
}
}
// Close button
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White
)
}
// Description at bottom if available
media.description?.let { desc ->
if (desc.isNotBlank()) {
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
color = Color.Black.copy(alpha = 0.7f)
) {
Text(
text = desc,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
}
/**
* Image viewer component with GIF support
*/
@Composable
private fun ImageViewer(
url: String,
description: String?,
isGif: Boolean
) {
val context = LocalContext.current
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.apply {
if (isGif) {
// Use GIF decoder for animated GIFs
if (android.os.Build.VERSION.SDK_INT >= 28) {
decoderFactory(ImageDecoderDecoder.Factory())
} else {
decoderFactory(GifDecoder.Factory())
}
}
}
.build(),
contentDescription = description ?: "Media attachment",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
/**
* Video viewer component with Media3 ExoPlayer
*/
@Composable
private fun VideoViewer(
url: String,
description: String?
) {
val context = LocalContext.current
// Create ExoPlayer instance
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(Uri.parse(url)))
prepare()
playWhenReady = true
}
}
// Release player when disposed
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = true
controllerShowTimeoutMs = 3000
controllerHideOnTouch = true
}
},
modifier = Modifier.fillMaxSize()
)
}
/**
* Media preview with play button overlay for videos
*/
@Composable
fun MediaPreview(
media: MediaAttachment,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clickable(onClick = onClick)
) {
// Show thumbnail or image
AsyncImage(
model = media.previewUrl ?: media.url,
contentDescription = media.description ?: "Media attachment",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Show play button overlay for videos
if (media.type == "video" || media.type == "gifv") {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(64.dp)
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Play",
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
tint = Color.Black
)
}
}
}
}
}

Ver fichero

@@ -0,0 +1,314 @@
package com.manalejandro.myactivitypub.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
/**
* Data class for poll configuration
*/
data class PollData(
val options: List<String> = listOf("", ""),
val expiresIn: Int = 86400, // 24 hours in seconds
val multiple: Boolean = false
)
/**
* Poll creator dialog
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PollCreatorDialog(
onDismiss: () -> Unit,
onPollCreated: (PollData) -> Unit,
initialPoll: PollData? = null
) {
var options by remember { mutableStateOf(initialPoll?.options ?: listOf("", "")) }
var expirationIndex by remember { mutableStateOf(2) } // Default: 1 day
var allowMultiple by remember { mutableStateOf(initialPoll?.multiple ?: false) }
val expirationOptions = listOf(
"5 minutes" to 300,
"30 minutes" to 1800,
"1 hour" to 3600,
"6 hours" to 21600,
"1 day" to 86400,
"3 days" to 259200,
"7 days" to 604800
)
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 600.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Create a poll",
style = MaterialTheme.typography.titleLarge
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Poll options
Text(
"Options",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
options.forEachIndexed { index, option ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = option,
onValueChange = { newValue ->
options = options.toMutableList().apply {
set(index, newValue)
}
},
label = { Text("Option ${index + 1}") },
modifier = Modifier.weight(1f),
singleLine = true
)
if (options.size > 2) {
IconButton(
onClick = {
options = options.toMutableList().apply {
removeAt(index)
}
}
) {
Icon(Icons.Default.Remove, "Remove option")
}
}
}
}
// Add option button
if (options.size < 4) {
TextButton(
onClick = {
options = options + ""
},
modifier = Modifier.padding(vertical = 8.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add option")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Expiration
Text(
"Poll duration",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = expirationOptions[expirationIndex].first,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
expirationOptions.forEachIndexed { index, (label, _) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
expirationIndex = index
expanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Multiple choice
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = allowMultiple,
onCheckedChange = { allowMultiple = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text("Allow multiple choices")
}
Spacer(modifier = Modifier.height(24.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
val validOptions = options.filter { it.isNotBlank() }
if (validOptions.size >= 2) {
onPollCreated(
PollData(
options = validOptions,
expiresIn = expirationOptions[expirationIndex].second,
multiple = allowMultiple
)
)
}
},
enabled = options.count { it.isNotBlank() } >= 2
) {
Text("Create")
}
}
}
}
}
}
/**
* Poll preview card for compose screen
*/
@Composable
fun PollPreviewCard(
poll: PollData,
onRemove: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Poll",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Close,
contentDescription = "Remove poll",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(12.dp))
poll.options.forEachIndexed { index, option ->
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.small
) {
Text(
text = if (option.isNotBlank()) option else "Option ${index + 1}",
modifier = Modifier.padding(12.dp),
color = if (option.isNotBlank())
MaterialTheme.colorScheme.onSurface
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (poll.multiple) "Multiple choices" else "Single choice",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatDuration(poll.expiresIn),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Format seconds to readable duration
*/
private fun formatDuration(seconds: Int): String {
return when {
seconds < 3600 -> "${seconds / 60} minutes"
seconds < 86400 -> "${seconds / 3600} hours"
else -> "${seconds / 86400} days"
}
}

Ver fichero

@@ -14,6 +14,10 @@ import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -27,6 +31,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.manalejandro.myactivitypub.data.models.MediaAttachment
import com.manalejandro.myactivitypub.data.models.Status
import java.text.SimpleDateFormat
import java.util.*
@@ -42,8 +47,19 @@ fun StatusCard(
onBoostClick: (Status) -> Unit = {},
onFavoriteClick: (Status) -> Unit = {},
onStatusClick: (Status) -> Unit = {},
onHashtagClick: (String) -> Unit = {},
isAuthenticated: Boolean = false
) {
var selectedMedia by remember { mutableStateOf<MediaAttachment?>(null) }
// Show media viewer dialog if media is selected
selectedMedia?.let { media ->
MediaViewerDialog(
media = media,
onDismiss = { selectedMedia = null }
)
}
Card(
modifier = modifier
.fillMaxWidth()
@@ -101,12 +117,18 @@ fun StatusCard(
text = annotatedContent,
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
onClick = { offset ->
// Check for URL
annotatedContent.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
// Open URL in browser
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item))
context.startActivity(intent)
}
// Check for hashtag
annotatedContent.getStringAnnotations(tag = "HASHTAG", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
onHashtagClick(annotation.item)
}
}
)
@@ -114,14 +136,13 @@ fun StatusCard(
if (status.mediaAttachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
status.mediaAttachments.forEach { media ->
AsyncImage(
model = media.url,
contentDescription = media.description ?: "Media attachment",
MediaPreview(
media = media,
onClick = { selectedMedia = media },
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
.clip(MaterialTheme.shapes.medium)
)
Spacer(modifier = Modifier.height(8.dp))
}
@@ -266,8 +287,10 @@ private fun parseHtmlContent(html: String, linkColor: Color) = buildAnnotatedStr
// Find all hashtags
hashtagPattern.findAll(plainText).forEach { match ->
val hashtagRange = match.groups[2]?.range
if (hashtagRange != null) {
allMatches.add(hashtagRange to "#${match.groups[2]?.value}")
val hashtagValue = match.groups[2]?.value
if (hashtagRange != null && hashtagValue != null) {
// Don't add # prefix as it's already in the text
allMatches.add(hashtagRange to hashtagValue)
}
}
@@ -289,8 +312,9 @@ private fun parseHtmlContent(html: String, linkColor: Color) = buildAnnotatedStr
textDecoration = TextDecoration.Underline
)
) {
pushStringAnnotation(tag = "URL", annotation = value)
append(if (value.startsWith("#")) value else plainText.substring(range))
val tag = if (value.startsWith("http")) "URL" else "HASHTAG"
pushStringAnnotation(tag = tag, annotation = value)
append(if (value.startsWith("http")) plainText.substring(range) else "#${value}")
pop()
}

Ver fichero

@@ -0,0 +1,411 @@
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.input.TextFieldValue
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.manalejandro.myactivitypub.data.models.Status
import com.manalejandro.myactivitypub.ui.components.*
/**
* Full-screen compose reply interface with all features
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComposeReplyScreen(
status: Status,
onBack: () -> Unit,
onReply: (content: String, contentWarning: String?, visibility: String) -> Unit,
isPosting: Boolean = false
) {
var replyText 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 = androidx.compose.ui.text.TextRange(selection.start + emoji.length)
)
replyText = newText
}
)
}
// Show poll creator dialog
if (showPollCreator) {
PollCreatorDialog(
onDismiss = { showPollCreator = false },
onPollCreated = { newPoll ->
poll = newPoll
showPollCreator = false
},
initialPoll = poll
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Reply") },
navigationIcon = {
IconButton(onClick = onBack, enabled = !isPosting) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
Button(
onClick = {
if (replyText.isNotBlank() && replyText.length <= 500) {
onReply(
replyText,
if (showCW && contentWarning.isNotBlank()) contentWarning else null,
visibility
)
}
},
enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting,
modifier = Modifier.padding(end = 8.dp)
) {
if (isPosting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Post")
}
}
}
)
},
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)
) {
// Original status preview
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = status.account.displayName.ifEmpty { status.account.username },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "@${status.account.acct}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = android.text.Html.fromHtml(
status.content,
android.text.Html.FROM_HTML_MODE_COMPACT
).toString(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 4
)
}
}
// Replying to indicator
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Reply,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Replying to @${status.account.acct}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(8.dp))
// Content Warning field
if (showCW) {
OutlinedTextField(
value = contentWarning,
onValueChange = { contentWarning = it },
label = { Text("Content Warning") },
placeholder = { Text("Describe sensitive content") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
singleLine = true,
enabled = !isPosting,
trailingIcon = {
IconButton(onClick = {
showCW = false
contentWarning = ""
}) {
Icon(Icons.Default.Close, "Remove CW")
}
}
)
Spacer(modifier = Modifier.height(16.dp))
}
// Media picker - always present to register launcher
MediaPicker(
selectedMedia = selectedMedia,
onMediaSelected = { uris -> selectedMedia = uris },
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
replyText = it.text
},
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp)
.heightIn(min = 200.dp),
placeholder = { Text("Write your reply...") },
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 = "${replyText.length}/500",
style = MaterialTheme.typography.bodySmall,
color = if (replyText.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 (replyText.length > 500) {
Text(
text = "⚠️ Text is too long (${replyText.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
}
)
}
}
}
}
/**
* Bottom bar with compose actions
*/
@Composable
fun ComposeBottomBar(
onImageClick: () -> Unit,
onEmojiClick: () -> Unit,
onPollClick: () -> Unit,
onCWClick: () -> Unit,
onVisibilityClick: () -> Unit,
visibility: String,
showCW: Boolean,
enabled: Boolean
) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onImageClick, enabled = enabled) {
Icon(
Icons.Default.Image,
contentDescription = "Add image",
tint = if (enabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
IconButton(onClick = onEmojiClick, enabled = enabled) {
Icon(
Icons.Default.EmojiEmotions,
contentDescription = "Add emoji",
tint = if (enabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
IconButton(onClick = onPollClick, enabled = enabled) {
Icon(
Icons.Default.Poll,
contentDescription = "Add poll",
tint = if (enabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
IconButton(onClick = onCWClick, enabled = enabled) {
Icon(
Icons.Default.Warning,
contentDescription = "Content warning",
tint = if (showCW) MaterialTheme.colorScheme.primary
else if (enabled) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
IconButton(onClick = onVisibilityClick, enabled = enabled) {
Icon(
when (visibility) {
"public" -> Icons.Default.Public
"unlisted" -> Icons.Default.Lock
"private" -> Icons.Default.LockOpen
"direct" -> Icons.Default.Mail
else -> Icons.Default.Public
},
contentDescription = "Visibility: $visibility",
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
}
}

Ver fichero

@@ -0,0 +1,183 @@
package com.manalejandro.myactivitypub.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.manalejandro.myactivitypub.data.models.Status
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
import com.manalejandro.myactivitypub.ui.components.StatusCard
/**
* Screen for displaying a timeline of statuses with a specific hashtag
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HashtagTimelineScreen(
hashtag: String,
repository: MastodonRepository,
onBack: () -> Unit,
onStatusClick: (Status) -> Unit = {},
onReplyClick: (Status) -> Unit = {},
onHashtagClick: (String) -> Unit = {},
isAuthenticated: Boolean = false
) {
val viewModel: com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineViewModel = viewModel(key = hashtag) {
com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineViewModel(repository, hashtag)
}
val uiState by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("#$hashtag", fontWeight = FontWeight.Bold)
Text(
"Trending hashtag",
style = MaterialTheme.typography.bodySmall
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
when (val state = uiState) {
is com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineUiState.Success -> {
if (state.statuses.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"No posts found for #$hashtag",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Be the first to post!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
state = listState,
contentPadding = PaddingValues(vertical = 8.dp)
) {
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "#$hashtag",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${state.statuses.size} posts",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
items(state.statuses, key = { it.id }) { status ->
StatusCard(
status = status,
onStatusClick = onStatusClick,
onReplyClick = onReplyClick,
onBoostClick = { viewModel.toggleBoost(status.id) },
onFavoriteClick = { viewModel.toggleFavorite(status.id) },
onHashtagClick = onHashtagClick,
isAuthenticated = isAuthenticated
)
}
// Load more indicator
item {
if (state.hasMore) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Button(onClick = { viewModel.loadMore() }) {
Text("Load more")
}
}
}
}
}
}
}
is com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Error loading hashtag",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
state.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadHashtagTimeline() }) {
Text("Retry")
}
}
}
}
}
}
}

Ver fichero

@@ -36,6 +36,7 @@ fun StatusDetailScreen(
onBackClick: () -> Unit,
onReplyClick: (Status) -> Unit = {},
onStatusUpdated: (Status) -> Unit = {},
onHashtagClick: (String) -> Unit = {},
isAuthenticated: Boolean = false
) {
// Create ViewModel for this status
@@ -133,6 +134,7 @@ fun StatusDetailScreen(
onReplyClick = onReplyClick,
onBoostClick = { /* Individual reply boost */ },
onFavoriteClick = { /* Individual reply favorite */ },
onHashtagClick = onHashtagClick,
isAuthenticated = isAuthenticated
)
}
@@ -169,6 +171,16 @@ private fun DetailedStatusCard(
onFavoriteClick: (Status) -> Unit,
isAuthenticated: Boolean
) {
var selectedMedia by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.MediaAttachment?>(null) }
// Show media viewer dialog if media is selected
selectedMedia?.let { media ->
com.manalejandro.myactivitypub.ui.components.MediaViewerDialog(
media = media,
onDismiss = { selectedMedia = null }
)
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -225,18 +237,15 @@ private fun DetailedStatusCard(
if (status.mediaAttachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
status.mediaAttachments.forEach { media ->
if (media.type == "image") {
AsyncImage(
model = media.url,
contentDescription = media.description,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
com.manalejandro.myactivitypub.ui.components.MediaPreview(
media = media,
onClick = { selectedMedia = media },
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(MaterialTheme.shapes.medium)
)
Spacer(modifier = Modifier.height(8.dp))
}
}

Ver fichero

@@ -0,0 +1,141 @@
package com.manalejandro.myactivitypub.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.myactivitypub.data.models.Status
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for managing hashtag timeline state
*/
class HashtagTimelineViewModel(
private val repository: MastodonRepository,
private val hashtag: String
) : ViewModel() {
private val _uiState = MutableStateFlow<HashtagTimelineUiState>(HashtagTimelineUiState.Loading)
val uiState: StateFlow<HashtagTimelineUiState> = _uiState.asStateFlow()
private var maxId: String? = null
init {
loadHashtagTimeline()
}
/**
* Load hashtag timeline
*/
fun loadHashtagTimeline() {
viewModelScope.launch {
_uiState.value = HashtagTimelineUiState.Loading
repository.getHashtagTimeline(hashtag).fold(
onSuccess = { statuses ->
maxId = statuses.lastOrNull()?.id
_uiState.value = HashtagTimelineUiState.Success(
statuses = statuses,
hasMore = statuses.size >= 20
)
},
onFailure = { error ->
_uiState.value = HashtagTimelineUiState.Error(
error.message ?: "Failed to load hashtag timeline"
)
}
)
}
}
/**
* Load more statuses
*/
fun loadMore() {
val currentState = _uiState.value as? HashtagTimelineUiState.Success ?: return
viewModelScope.launch {
repository.getHashtagTimeline(hashtag, maxId = maxId).fold(
onSuccess = { newStatuses ->
if (newStatuses.isNotEmpty()) {
maxId = newStatuses.lastOrNull()?.id
_uiState.value = HashtagTimelineUiState.Success(
statuses = currentState.statuses + newStatuses,
hasMore = newStatuses.size >= 20
)
} else {
_uiState.value = currentState.copy(hasMore = false)
}
},
onFailure = { error ->
// Keep current state but show error
println("Error loading more: ${error.message}")
}
)
}
}
/**
* Toggle favorite on a status
*/
fun toggleFavorite(statusId: String) {
viewModelScope.launch {
val currentState = _uiState.value as? HashtagTimelineUiState.Success ?: return@launch
val status = currentState.statuses.find { it.id == statusId } ?: return@launch
val result = if (status.favourited) {
repository.unfavoriteStatus(statusId)
} else {
repository.favoriteStatus(statusId)
}
result.onSuccess { updatedStatus ->
_uiState.value = HashtagTimelineUiState.Success(
statuses = currentState.statuses.map {
if (it.id == statusId) updatedStatus else it
},
hasMore = currentState.hasMore
)
}
}
}
/**
* Toggle boost on a status
*/
fun toggleBoost(statusId: String) {
viewModelScope.launch {
val currentState = _uiState.value as? HashtagTimelineUiState.Success ?: return@launch
val status = currentState.statuses.find { it.id == statusId } ?: return@launch
val result = if (status.reblogged) {
repository.unboostStatus(statusId)
} else {
repository.boostStatus(statusId)
}
result.onSuccess { updatedStatus ->
_uiState.value = HashtagTimelineUiState.Success(
statuses = currentState.statuses.map {
if (it.id == statusId) updatedStatus else it
},
hasMore = currentState.hasMore
)
}
}
}
}
/**
* UI State for the Hashtag Timeline screen
*/
sealed class HashtagTimelineUiState {
object Loading : HashtagTimelineUiState()
data class Success(
val statuses: List<Status>,
val hasMore: Boolean = false
) : HashtagTimelineUiState()
data class Error(val message: String) : HashtagTimelineUiState()
}