Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-01-24 18:29:54 +01:00
padre 8c7417f913
commit 2d33461f40
Se han modificado 24 ficheros con 722 adiciones y 77 borrados

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 25 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 20 KiB

Ver fichero

@@ -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(
}
}
}
}
}
}
}

Ver fichero

@@ -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>>
/**

Ver fichero

@@ -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 {

Ver fichero

@@ -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))
}
}

Ver fichero

@@ -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)
*/

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 1.9 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 1.5 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 3.4 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 3.3 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 1.6 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 916 B

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 2.4 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 2.1 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 2.7 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 2.2 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 4.8 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 4.9 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 4.0 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 3.0 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 7.7 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 7.4 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 5.3 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 3.9 KiB

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 10 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 10 KiB

Ver fichero

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1C6EA9</color>
</resources>