|
Antes Anchura: | Altura: | Tamaño: 25 KiB Después Anchura: | Altura: | Tamaño: 20 KiB |
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -236,6 +237,7 @@ fun TimelineScreen(
|
||||
val timelineType by viewModel.timelineType.collectAsState()
|
||||
val interactionState by viewModel.interactionState.collectAsState()
|
||||
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var replyingToStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) }
|
||||
@@ -472,27 +474,31 @@ fun TimelineScreen(
|
||||
|
||||
is TimelineUiState.Success -> {
|
||||
val statuses = (uiState as TimelineUiState.Success).statuses
|
||||
if (statuses.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No posts available",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = timelineScrollState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { viewModel.refresh() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (statuses.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No posts available",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = timelineScrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
item {
|
||||
// Timeline type indicator
|
||||
Surface(
|
||||
@@ -566,6 +572,7 @@ fun TimelineScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,22 +48,27 @@ interface MastodonApiService {
|
||||
* @param limit Maximum number of statuses to return (default 20)
|
||||
* @param local Show only local statuses
|
||||
* @param maxId Get statuses older than this ID (for pagination)
|
||||
* @param sinceId Get statuses newer than this ID (for auto-refresh)
|
||||
*/
|
||||
@GET("api/v1/timelines/public")
|
||||
suspend fun getPublicTimeline(
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("local") local: Boolean = false,
|
||||
@Query("max_id") maxId: String? = null
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("since_id") sinceId: String? = null
|
||||
): Response<List<Status>>
|
||||
|
||||
/**
|
||||
* Get home timeline (requires authentication)
|
||||
* @param limit Maximum number of statuses to return
|
||||
* @param maxId Get statuses older than this ID (for pagination)
|
||||
* @param sinceId Get statuses newer than this ID (for auto-refresh)
|
||||
*/
|
||||
@GET("api/v1/timelines/home")
|
||||
suspend fun getHomeTimeline(
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("max_id") maxId: String? = null
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("since_id") sinceId: String? = null
|
||||
): Response<List<Status>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,10 +16,10 @@ class MastodonRepository(private val apiService: MastodonApiService) {
|
||||
/**
|
||||
* Fetch the public timeline (federated)
|
||||
*/
|
||||
suspend fun getPublicTimeline(limit: Int = 20, local: Boolean = false, maxId: String? = null): Result<List<Status>> {
|
||||
suspend fun getPublicTimeline(limit: Int = 20, local: Boolean = false, maxId: String? = null, sinceId: String? = null): Result<List<Status>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.getPublicTimeline(limit, local, maxId)
|
||||
val response = apiService.getPublicTimeline(limit, local, maxId, sinceId)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body() ?: emptyList())
|
||||
} else {
|
||||
@@ -34,10 +34,10 @@ class MastodonRepository(private val apiService: MastodonApiService) {
|
||||
/**
|
||||
* Fetch the home timeline (requires authentication)
|
||||
*/
|
||||
suspend fun getHomeTimeline(limit: Int = 20, maxId: String? = null): Result<List<Status>> {
|
||||
suspend fun getHomeTimeline(limit: Int = 20, maxId: String? = null, sinceId: String? = null): Result<List<Status>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.getHomeTimeline(limit, maxId)
|
||||
val response = apiService.getHomeTimeline(limit, maxId, sinceId)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body() ?: emptyList())
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.manalejandro.myactivitypub.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.Html
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
@@ -14,8 +17,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.Status
|
||||
@@ -83,12 +92,22 @@ fun StatusCard(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Content
|
||||
val htmlContent = Html.fromHtml(status.content, Html.FROM_HTML_MODE_COMPACT).toString()
|
||||
Text(
|
||||
text = htmlContent,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
// Content with clickable links
|
||||
val context = LocalContext.current
|
||||
val annotatedContent = parseHtmlContent(status.content, MaterialTheme.colorScheme.primary)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
ClickableText(
|
||||
text = annotatedContent,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
onClick = { offset ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Media attachments
|
||||
@@ -209,7 +228,7 @@ private fun formatTimestamp(timestamp: String): String {
|
||||
diff < 86400000 -> "${diff / 3600000}h"
|
||||
else -> "${diff / 86400000}d"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -224,3 +243,62 @@ private fun formatCount(count: Int): String {
|
||||
else -> count.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML content and create AnnotatedString with clickable links
|
||||
*/
|
||||
@Composable
|
||||
private fun parseHtmlContent(html: String, linkColor: Color) = buildAnnotatedString {
|
||||
// Convert HTML to plain text but extract links
|
||||
val plainText = Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT).toString()
|
||||
|
||||
// Regex patterns for URLs and hashtags
|
||||
val urlPattern = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""")
|
||||
val hashtagPattern = Regex("""(^|\s)#(\w+)""")
|
||||
|
||||
val allMatches = mutableListOf<Pair<IntRange, String>>()
|
||||
|
||||
// Find all URLs
|
||||
urlPattern.findAll(plainText).forEach { match ->
|
||||
allMatches.add(match.range to match.value)
|
||||
}
|
||||
|
||||
// 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}")
|
||||
}
|
||||
}
|
||||
|
||||
// Sort matches by position
|
||||
allMatches.sortBy { it.first.first }
|
||||
|
||||
// Build annotated string
|
||||
var currentIndex = 0
|
||||
allMatches.forEach { (range, value) ->
|
||||
// Add text before the link
|
||||
if (range.first > currentIndex) {
|
||||
append(plainText.substring(currentIndex, range.first))
|
||||
}
|
||||
|
||||
// Add the link with annotation
|
||||
withStyle(
|
||||
style = SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
) {
|
||||
pushStringAnnotation(tag = "URL", annotation = value)
|
||||
append(if (value.startsWith("#")) value else plainText.substring(range))
|
||||
pop()
|
||||
}
|
||||
|
||||
currentIndex = range.last + 1
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (currentIndex < plainText.length) {
|
||||
append(plainText.substring(currentIndex))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,20 @@ class TimelineViewModel(private val repository: MastodonRepository) : ViewModel(
|
||||
private val _isLoadingMore = MutableStateFlow(false)
|
||||
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
private var currentStatuses = mutableListOf<Status>()
|
||||
private var autoRefreshJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
loadTimeline()
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
autoRefreshJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +49,7 @@ class TimelineViewModel(private val repository: MastodonRepository) : ViewModel(
|
||||
_timelineType.value = type
|
||||
currentStatuses.clear()
|
||||
loadTimeline()
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +77,75 @@ class TimelineViewModel(private val repository: MastodonRepository) : ViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the timeline (pull-to-refresh)
|
||||
*/
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
|
||||
val result = when (_timelineType.value) {
|
||||
TimelineType.PUBLIC -> repository.getPublicTimeline()
|
||||
TimelineType.HOME -> repository.getHomeTimeline()
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { statuses ->
|
||||
currentStatuses.clear()
|
||||
currentStatuses.addAll(statuses)
|
||||
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
|
||||
},
|
||||
onFailure = { error ->
|
||||
// Keep current state on refresh error, just stop refreshing
|
||||
_uiState.value = TimelineUiState.Error(error.message ?: "Unknown error occurred")
|
||||
}
|
||||
)
|
||||
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh for timeline (every 30 seconds)
|
||||
*/
|
||||
private fun startAutoRefresh() {
|
||||
autoRefreshJob?.cancel()
|
||||
autoRefreshJob = viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(30000) // Wait 30 seconds
|
||||
while (true) {
|
||||
checkForNewStatuses()
|
||||
kotlinx.coroutines.delay(30000) // Check every 30 seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for new statuses and update if available
|
||||
*/
|
||||
private suspend fun checkForNewStatuses() {
|
||||
if (_isRefreshing.value || _isLoadingMore.value || currentStatuses.isEmpty()) return
|
||||
|
||||
val sinceId = currentStatuses.firstOrNull()?.id
|
||||
|
||||
val result = when (_timelineType.value) {
|
||||
TimelineType.PUBLIC -> repository.getPublicTimeline(limit = 20, local = false, maxId = null, sinceId = sinceId)
|
||||
TimelineType.HOME -> repository.getHomeTimeline(limit = 20, maxId = null, sinceId = sinceId)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { newStatuses ->
|
||||
if (newStatuses.isNotEmpty()) {
|
||||
// Add new statuses at the beginning
|
||||
currentStatuses.addAll(0, newStatuses)
|
||||
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
// Silently fail for auto-refresh
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more statuses (pagination)
|
||||
*/
|
||||
|
||||
@@ -3,35 +3,40 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.6"
|
||||
android:scaleY="0.6"
|
||||
android:translateX="21.6"
|
||||
android:translateY="21.6">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#2B90D9"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#2B90D9"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
|
||||
<!-- Rounded square background -->
|
||||
<path
|
||||
android:fillColor="#1A6DA8"
|
||||
android:pathData="M24,18 Q18,18 18,24 L18,84 Q18,90 24,90 L84,90 Q90,90 90,84 L90,24 Q90,18 84,18 Z"/>
|
||||
<!-- Rounded square background -->
|
||||
<path
|
||||
android:fillColor="#1A6DA8"
|
||||
android:pathData="M24,18 Q18,18 18,24 L18,84 Q18,90 24,90 L84,90 Q90,90 90,84 L90,24 Q90,18 84,18 Z"/>
|
||||
|
||||
<!-- Main background -->
|
||||
<path
|
||||
android:fillColor="#2B90D9"
|
||||
android:pathData="M26,20 Q20,20 20,26 L20,82 Q20,88 26,88 L82,88 Q88,88 88,82 L88,26 Q88,20 82,20 Z"/>
|
||||
<!-- Main background -->
|
||||
<path
|
||||
android:fillColor="#2B90D9"
|
||||
android:pathData="M26,20 Q20,20 20,26 L20,82 Q20,88 26,88 L82,88 Q88,88 88,82 L88,26 Q88,20 82,20 Z"/>
|
||||
|
||||
<!-- M letter -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,35 L30,73 L34,73 L34,42 L45,65 L49,65 L60,42 L60,73 L64,73 L64,35 L58,35 L47,62 L36,35 Z"/>
|
||||
<!-- M letter -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,35 L30,73 L34,73 L34,42 L45,65 L49,65 L60,42 L60,73 L64,73 L64,35 L58,35 L47,62 L36,35 Z"/>
|
||||
|
||||
<!-- y letter -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M68,50 L68,65 L64,73 L68,73 L72,67 L76,73 L80,73 L74,64 L74,50 Z"/>
|
||||
<!-- y letter -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M68,50 L68,65 L64,73 L68,73 L72,67 L76,73 L80,73 L74,64 L74,50 Z"/>
|
||||
|
||||
<!-- Small fediverse star -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="M54,28 L55.5,32 L59.5,32 L56.5,34.5 L58,38.5 L54,36 L50,38.5 L51.5,34.5 L48.5,32 L52.5,32 Z"/>
|
||||
<!-- Small fediverse star -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="M54,28 L55.5,32 L59.5,32 L56.5,34.5 L58,38.5 L54,36 L50,38.5 L51.5,34.5 L48.5,32 L52.5,32 Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Antes Anchura: | Altura: | Tamaño: 1.9 KiB Después Anchura: | Altura: | Tamaño: 1.5 KiB |
|
Antes Anchura: | Altura: | Tamaño: 3.4 KiB Después Anchura: | Altura: | Tamaño: 3.3 KiB |
|
Antes Anchura: | Altura: | Tamaño: 1.6 KiB Después Anchura: | Altura: | Tamaño: 916 B |
|
Antes Anchura: | Altura: | Tamaño: 2.4 KiB Después Anchura: | Altura: | Tamaño: 2.1 KiB |
|
Antes Anchura: | Altura: | Tamaño: 2.7 KiB Después Anchura: | Altura: | Tamaño: 2.2 KiB |
|
Antes Anchura: | Altura: | Tamaño: 4.8 KiB Después Anchura: | Altura: | Tamaño: 4.9 KiB |
|
Antes Anchura: | Altura: | Tamaño: 4.0 KiB Después Anchura: | Altura: | Tamaño: 3.0 KiB |
|
Antes Anchura: | Altura: | Tamaño: 7.7 KiB Después Anchura: | Altura: | Tamaño: 7.4 KiB |
|
Antes Anchura: | Altura: | Tamaño: 5.3 KiB Después Anchura: | Altura: | Tamaño: 3.9 KiB |
|
Antes Anchura: | Altura: | Tamaño: 10 KiB Después Anchura: | Altura: | Tamaño: 10 KiB |
4
app/src/main/res/values/ic_launcher_background.xml
Archivo normal
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1C6EA9</color>
|
||||
</resources>
|
||||