@@ -56,8 +56,9 @@ dependencies {
|
|||||||
|
|
||||||
// Coil for image loading
|
// Coil for image loading
|
||||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
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")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
@@ -72,6 +73,10 @@ dependencies {
|
|||||||
// Browser for OAuth
|
// Browser for OAuth
|
||||||
implementation("androidx.browser:browser:1.7.0")
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -242,15 +242,24 @@ fun TimelineScreen(
|
|||||||
var showNotifications by remember { mutableStateOf(false) }
|
var showNotifications by remember { mutableStateOf(false) }
|
||||||
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) }
|
||||||
|
|
||||||
// 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 = replyingToStatus != null) {
|
||||||
|
replyingToStatus = null
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = selectedStatus != null) {
|
BackHandler(enabled = selectedStatus != null) {
|
||||||
selectedStatus = null
|
selectedStatus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = selectedHashtag != null) {
|
||||||
|
selectedHashtag = null
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = showNotifications) {
|
BackHandler(enabled = showNotifications) {
|
||||||
showNotifications = false
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -380,8 +378,36 @@ fun TimelineScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// Show status detail if selected
|
// Show compose reply screen if replying
|
||||||
if (selectedStatus != null) {
|
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
|
// Create repository for status detail
|
||||||
val detailApiService = remember(userSession) {
|
val detailApiService = remember(userSession) {
|
||||||
createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken)
|
createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken)
|
||||||
@@ -395,6 +421,7 @@ fun TimelineScreen(
|
|||||||
repository = detailRepository,
|
repository = detailRepository,
|
||||||
onBackClick = { selectedStatus = null },
|
onBackClick = { selectedStatus = null },
|
||||||
onReplyClick = { replyingToStatus = it },
|
onReplyClick = { replyingToStatus = it },
|
||||||
|
onHashtagClick = { selectedHashtag = it },
|
||||||
onStatusUpdated = { updatedStatus ->
|
onStatusUpdated = { updatedStatus ->
|
||||||
viewModel.updateStatus(updatedStatus)
|
viewModel.updateStatus(updatedStatus)
|
||||||
},
|
},
|
||||||
@@ -537,6 +564,7 @@ fun TimelineScreen(
|
|||||||
onReplyClick = { replyingToStatus = it },
|
onReplyClick = { replyingToStatus = it },
|
||||||
onBoostClick = { viewModel.toggleBoost(it) },
|
onBoostClick = { viewModel.toggleBoost(it) },
|
||||||
onFavoriteClick = { viewModel.toggleFavorite(it) },
|
onFavoriteClick = { viewModel.toggleFavorite(it) },
|
||||||
|
onHashtagClick = { selectedHashtag = it },
|
||||||
isAuthenticated = userSession != null
|
isAuthenticated = userSession != null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,4 +144,17 @@ interface MastodonApiService {
|
|||||||
*/
|
*/
|
||||||
@GET("api/v1/statuses/{id}/context")
|
@GET("api/v1/statuses/{id}/context")
|
||||||
suspend fun getStatusContext(@Path("id") statusId: String): Response<StatusContext>
|
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>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import androidx.compose.material.icons.filled.Repeat
|
|||||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.manalejandro.myactivitypub.data.models.MediaAttachment
|
||||||
import com.manalejandro.myactivitypub.data.models.Status
|
import com.manalejandro.myactivitypub.data.models.Status
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -42,8 +47,19 @@ fun StatusCard(
|
|||||||
onBoostClick: (Status) -> Unit = {},
|
onBoostClick: (Status) -> Unit = {},
|
||||||
onFavoriteClick: (Status) -> Unit = {},
|
onFavoriteClick: (Status) -> Unit = {},
|
||||||
onStatusClick: (Status) -> Unit = {},
|
onStatusClick: (Status) -> Unit = {},
|
||||||
|
onHashtagClick: (String) -> Unit = {},
|
||||||
isAuthenticated: Boolean = false
|
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(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -101,12 +117,18 @@ fun StatusCard(
|
|||||||
text = annotatedContent,
|
text = annotatedContent,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
|
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
onClick = { offset ->
|
onClick = { offset ->
|
||||||
|
// Check for URL
|
||||||
annotatedContent.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
annotatedContent.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||||
.firstOrNull()?.let { annotation ->
|
.firstOrNull()?.let { annotation ->
|
||||||
// Open URL in browser
|
// Open URL in browser
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item))
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item))
|
||||||
context.startActivity(intent)
|
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()) {
|
if (status.mediaAttachments.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
status.mediaAttachments.forEach { media ->
|
status.mediaAttachments.forEach { media ->
|
||||||
AsyncImage(
|
MediaPreview(
|
||||||
model = media.url,
|
media = media,
|
||||||
contentDescription = media.description ?: "Media attachment",
|
onClick = { selectedMedia = media },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 300.dp)
|
.heightIn(max = 300.dp)
|
||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium)
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -266,8 +287,10 @@ private fun parseHtmlContent(html: String, linkColor: Color) = buildAnnotatedStr
|
|||||||
// Find all hashtags
|
// Find all hashtags
|
||||||
hashtagPattern.findAll(plainText).forEach { match ->
|
hashtagPattern.findAll(plainText).forEach { match ->
|
||||||
val hashtagRange = match.groups[2]?.range
|
val hashtagRange = match.groups[2]?.range
|
||||||
if (hashtagRange != null) {
|
val hashtagValue = match.groups[2]?.value
|
||||||
allMatches.add(hashtagRange to "#${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
|
textDecoration = TextDecoration.Underline
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
pushStringAnnotation(tag = "URL", annotation = value)
|
val tag = if (value.startsWith("http")) "URL" else "HASHTAG"
|
||||||
append(if (value.startsWith("#")) value else plainText.substring(range))
|
pushStringAnnotation(tag = tag, annotation = value)
|
||||||
|
append(if (value.startsWith("http")) plainText.substring(range) else "#${value}")
|
||||||
pop()
|
pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ fun StatusDetailScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onReplyClick: (Status) -> Unit = {},
|
onReplyClick: (Status) -> Unit = {},
|
||||||
onStatusUpdated: (Status) -> Unit = {},
|
onStatusUpdated: (Status) -> Unit = {},
|
||||||
|
onHashtagClick: (String) -> Unit = {},
|
||||||
isAuthenticated: Boolean = false
|
isAuthenticated: Boolean = false
|
||||||
) {
|
) {
|
||||||
// Create ViewModel for this status
|
// Create ViewModel for this status
|
||||||
@@ -133,6 +134,7 @@ fun StatusDetailScreen(
|
|||||||
onReplyClick = onReplyClick,
|
onReplyClick = onReplyClick,
|
||||||
onBoostClick = { /* Individual reply boost */ },
|
onBoostClick = { /* Individual reply boost */ },
|
||||||
onFavoriteClick = { /* Individual reply favorite */ },
|
onFavoriteClick = { /* Individual reply favorite */ },
|
||||||
|
onHashtagClick = onHashtagClick,
|
||||||
isAuthenticated = isAuthenticated
|
isAuthenticated = isAuthenticated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -169,6 +171,16 @@ private fun DetailedStatusCard(
|
|||||||
onFavoriteClick: (Status) -> Unit,
|
onFavoriteClick: (Status) -> Unit,
|
||||||
isAuthenticated: Boolean
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -225,18 +237,15 @@ private fun DetailedStatusCard(
|
|||||||
if (status.mediaAttachments.isNotEmpty()) {
|
if (status.mediaAttachments.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
status.mediaAttachments.forEach { media ->
|
status.mediaAttachments.forEach { media ->
|
||||||
if (media.type == "image") {
|
com.manalejandro.myactivitypub.ui.components.MediaPreview(
|
||||||
AsyncImage(
|
media = media,
|
||||||
model = media.url,
|
onClick = { selectedMedia = media },
|
||||||
contentDescription = media.description,
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
.heightIn(max = 300.dp)
|
||||||
.heightIn(max = 300.dp)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.clip(MaterialTheme.shapes.medium),
|
)
|
||||||
contentScale = ContentScale.Crop
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Referencia en una nueva incidencia
Block a user