48
README.md
@@ -8,13 +8,21 @@ My ActivityPub is a beautiful, user-friendly Android application that allows you
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📱 Modern Material Design 3 UI
|
### Core Features
|
||||||
- 🌐 Connect to any Mastodon/ActivityPub instance
|
- 📱 **Modern Material Design 3 UI** - Beautiful, responsive interface
|
||||||
- 📰 Browse public timelines
|
- 🌐 **Multi-Instance Support** - Connect to any Mastodon/ActivityPub instance
|
||||||
- 🖼️ View images and media attachments
|
- 🔐 **OAuth Authentication** - Secure login with OAuth 2.0
|
||||||
- 💬 See replies, boosts, and favorites
|
- 📰 **Public & Home Timelines** - Browse federated and personal timelines
|
||||||
- 🔄 Pull to refresh timeline
|
- 🔄 **Pull-to-Refresh** - Swipe down to update your timeline
|
||||||
- 🎨 Beautiful Mastodon-inspired color scheme
|
- ⚡ **Auto-Refresh** - Timeline automatically updates every 30 seconds
|
||||||
|
- 🔗 **Interactive Links & Hashtags** - Click URLs and hashtags in posts
|
||||||
|
- 🖼️ **Media Attachments** - View images and media in posts
|
||||||
|
- 💬 **Post Interactions** - Reply, boost, and favorite posts
|
||||||
|
- 📊 **Status Details** - View full posts with reply threads
|
||||||
|
- 🔔 **Notifications** - Stay updated with mentions and interactions
|
||||||
|
- ♾️ **Infinite Scrolling** - Load more posts as you scroll
|
||||||
|
- 🎨 **Dynamic Colors** - Adapts to your system theme (Android 12+)
|
||||||
|
- 🌙 **Dark Mode** - Full dark theme support
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -201,6 +209,32 @@ This project follows the official Kotlin coding conventions. Key guidelines:
|
|||||||
- Coil requires valid URLs
|
- Coil requires valid URLs
|
||||||
- Check LogCat for detailed error messages
|
- Check LogCat for detailed error messages
|
||||||
|
|
||||||
|
## Recent Updates
|
||||||
|
|
||||||
|
### Latest Features (v1.0)
|
||||||
|
|
||||||
|
#### 🔄 Pull-to-Refresh
|
||||||
|
The app now supports pull-to-refresh functionality on all timelines. Simply swipe down from the top of the timeline to fetch the latest posts from your instance.
|
||||||
|
|
||||||
|
#### ⚡ Automatic Timeline Updates
|
||||||
|
Timelines now automatically check for new posts every 30 seconds. New posts appear seamlessly at the top of your timeline without interrupting your reading experience.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- Background coroutine checks for new posts using `since_id` parameter
|
||||||
|
- Updates are silent and don't interfere with scrolling
|
||||||
|
- Auto-refresh restarts when switching between timelines
|
||||||
|
- Minimal network usage - only fetches posts newer than the current top post
|
||||||
|
|
||||||
|
#### 🔗 Interactive Content
|
||||||
|
Post content is now fully interactive with support for:
|
||||||
|
- **Clickable URLs**: Tap any link to open in your default browser
|
||||||
|
- **Hashtags**: Styled and clickable hashtags
|
||||||
|
- **Rich HTML content**: Proper rendering of formatted text
|
||||||
|
|
||||||
|
All links and hashtags are displayed in your theme's primary color with underlines for easy identification.
|
||||||
|
|
||||||
|
For detailed information about all features, see [FEATURES.md](docs/FEATURES.md).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please follow these steps:
|
Contributions are welcome! Please follow these steps:
|
||||||
|
|||||||
|
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.automirrored.filled.Login
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -236,6 +237,7 @@ fun TimelineScreen(
|
|||||||
val timelineType by viewModel.timelineType.collectAsState()
|
val timelineType by viewModel.timelineType.collectAsState()
|
||||||
val interactionState by viewModel.interactionState.collectAsState()
|
val interactionState by viewModel.interactionState.collectAsState()
|
||||||
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
||||||
|
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
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) }
|
||||||
@@ -472,27 +474,31 @@ fun TimelineScreen(
|
|||||||
|
|
||||||
is TimelineUiState.Success -> {
|
is TimelineUiState.Success -> {
|
||||||
val statuses = (uiState as TimelineUiState.Success).statuses
|
val statuses = (uiState as TimelineUiState.Success).statuses
|
||||||
if (statuses.isEmpty()) {
|
|
||||||
Box(
|
PullToRefreshBox(
|
||||||
modifier = Modifier
|
isRefreshing = isRefreshing,
|
||||||
.fillMaxSize()
|
onRefresh = { viewModel.refresh() },
|
||||||
.padding(paddingValues),
|
modifier = Modifier
|
||||||
contentAlignment = Alignment.Center
|
.fillMaxSize()
|
||||||
) {
|
.padding(paddingValues)
|
||||||
Text(
|
) {
|
||||||
text = "No posts available",
|
if (statuses.isEmpty()) {
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
Box(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
contentAlignment = Alignment.Center
|
||||||
}
|
) {
|
||||||
} else {
|
Text(
|
||||||
LazyColumn(
|
text = "No posts available",
|
||||||
state = timelineScrollState,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
.fillMaxSize()
|
)
|
||||||
.padding(paddingValues),
|
}
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
} else {
|
||||||
) {
|
LazyColumn(
|
||||||
|
state = timelineScrollState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
item {
|
item {
|
||||||
// Timeline type indicator
|
// Timeline type indicator
|
||||||
Surface(
|
Surface(
|
||||||
@@ -566,6 +572,7 @@ fun TimelineScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,22 +48,27 @@ interface MastodonApiService {
|
|||||||
* @param limit Maximum number of statuses to return (default 20)
|
* @param limit Maximum number of statuses to return (default 20)
|
||||||
* @param local Show only local statuses
|
* @param local Show only local statuses
|
||||||
* @param maxId Get statuses older than this ID (for pagination)
|
* @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")
|
@GET("api/v1/timelines/public")
|
||||||
suspend fun getPublicTimeline(
|
suspend fun getPublicTimeline(
|
||||||
@Query("limit") limit: Int = 20,
|
@Query("limit") limit: Int = 20,
|
||||||
@Query("local") local: Boolean = false,
|
@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>>
|
): Response<List<Status>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get home timeline (requires authentication)
|
* Get home timeline (requires authentication)
|
||||||
|
* @param limit Maximum number of statuses to return
|
||||||
* @param maxId Get statuses older than this ID (for pagination)
|
* @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")
|
@GET("api/v1/timelines/home")
|
||||||
suspend fun getHomeTimeline(
|
suspend fun getHomeTimeline(
|
||||||
@Query("limit") limit: Int = 20,
|
@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>>
|
): Response<List<Status>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ class MastodonRepository(private val apiService: MastodonApiService) {
|
|||||||
/**
|
/**
|
||||||
* Fetch the public timeline (federated)
|
* 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) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val response = apiService.getPublicTimeline(limit, local, maxId)
|
val response = apiService.getPublicTimeline(limit, local, maxId, sinceId)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body() ?: emptyList())
|
Result.success(response.body() ?: emptyList())
|
||||||
} else {
|
} else {
|
||||||
@@ -34,10 +34,10 @@ class MastodonRepository(private val apiService: MastodonApiService) {
|
|||||||
/**
|
/**
|
||||||
* Fetch the home timeline (requires authentication)
|
* 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) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val response = apiService.getHomeTimeline(limit, maxId)
|
val response = apiService.getHomeTimeline(limit, maxId, sinceId)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body() ?: emptyList())
|
Result.success(response.body() ?: emptyList())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.manalejandro.myactivitypub.ui.components
|
package com.manalejandro.myactivitypub.ui.components
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
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.Status
|
import com.manalejandro.myactivitypub.data.models.Status
|
||||||
@@ -83,12 +92,22 @@ fun StatusCard(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Content
|
// Content with clickable links
|
||||||
val htmlContent = Html.fromHtml(status.content, Html.FROM_HTML_MODE_COMPACT).toString()
|
val context = LocalContext.current
|
||||||
Text(
|
val annotatedContent = parseHtmlContent(status.content, MaterialTheme.colorScheme.primary)
|
||||||
text = htmlContent,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
@Suppress("DEPRECATION")
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
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
|
// Media attachments
|
||||||
@@ -209,7 +228,7 @@ private fun formatTimestamp(timestamp: String): String {
|
|||||||
diff < 86400000 -> "${diff / 3600000}h"
|
diff < 86400000 -> "${diff / 3600000}h"
|
||||||
else -> "${diff / 86400000}d"
|
else -> "${diff / 86400000}d"
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,3 +243,62 @@ private fun formatCount(count: Int): String {
|
|||||||
else -> count.toString()
|
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)
|
private val _isLoadingMore = MutableStateFlow(false)
|
||||||
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||||
|
|
||||||
private var currentStatuses = mutableListOf<Status>()
|
private var currentStatuses = mutableListOf<Status>()
|
||||||
|
private var autoRefreshJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadTimeline()
|
loadTimeline()
|
||||||
|
startAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
autoRefreshJob?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +49,7 @@ class TimelineViewModel(private val repository: MastodonRepository) : ViewModel(
|
|||||||
_timelineType.value = type
|
_timelineType.value = type
|
||||||
currentStatuses.clear()
|
currentStatuses.clear()
|
||||||
loadTimeline()
|
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)
|
* Load more statuses (pagination)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,35 +3,40 @@
|
|||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
|
<group android:scaleX="0.6"
|
||||||
|
android:scaleY="0.6"
|
||||||
|
android:translateX="21.6"
|
||||||
|
android:translateY="21.6">
|
||||||
|
|
||||||
<!-- Background -->
|
<!-- Background -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#2B90D9"
|
android:fillColor="#2B90D9"
|
||||||
android:pathData="M0,0h108v108h-108z"/>
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
|
||||||
<!-- Rounded square background -->
|
<!-- Rounded square background -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#1A6DA8"
|
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"/>
|
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 -->
|
<!-- Main background -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#2B90D9"
|
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"/>
|
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 -->
|
<!-- M letter -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
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"/>
|
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 -->
|
<!-- y letter -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:pathData="M68,50 L68,65 L64,73 L68,73 L72,67 L76,73 L80,73 L74,64 L74,50 Z"/>
|
android:pathData="M68,50 L68,65 L64,73 L68,73 L72,67 L76,73 L80,73 L74,64 L74,50 Z"/>
|
||||||
|
|
||||||
<!-- Small fediverse star -->
|
<!-- Small fediverse star -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:fillAlpha="0.8"
|
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"/>
|
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>
|
</vector>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</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>
|
||||||
@@ -7,28 +7,36 @@ Welcome to the My ActivityPub documentation! This index will help you find the i
|
|||||||
- Features and screenshots
|
- Features and screenshots
|
||||||
- Tech stack overview
|
- Tech stack overview
|
||||||
- Quick installation instructions
|
- Quick installation instructions
|
||||||
2. **[SETUP.md](SETUP.md)** - Complete development environment setup
|
2. **[FEATURES.md](FEATURES.md)** - Detailed feature documentation
|
||||||
|
- Pull-to-refresh functionality
|
||||||
|
- Automatic timeline updates
|
||||||
|
- Interactive links and hashtags
|
||||||
|
- Timeline features
|
||||||
|
- Post interactions
|
||||||
|
- Authentication features
|
||||||
|
- Technical architecture
|
||||||
|
3. **[SETUP.md](SETUP.md)** - Complete development environment setup
|
||||||
- Prerequisites and required software
|
- Prerequisites and required software
|
||||||
- Step-by-step project setup
|
- Step-by-step project setup
|
||||||
- Building and running the app
|
- Building and running the app
|
||||||
- Troubleshooting common issues
|
- Troubleshooting common issues
|
||||||
- IDE configuration tips
|
- IDE configuration tips
|
||||||
### Development
|
### Development
|
||||||
3. **[ARCHITECTURE.md](ARCHITECTURE.md)** - Application architecture and design
|
4. **[ARCHITECTURE.md](ARCHITECTURE.md)** - Application architecture and design
|
||||||
- MVVM architecture explained
|
- MVVM architecture explained
|
||||||
- Layer responsibilities
|
- Layer responsibilities
|
||||||
- Data flow diagrams
|
- Data flow diagrams
|
||||||
- State management patterns
|
- State management patterns
|
||||||
- Threading model with coroutines
|
- Threading model with coroutines
|
||||||
- Testing strategies
|
- Testing strategies
|
||||||
4. **[API.md](API.md)** - Mastodon API integration documentation
|
5. **[API.md](API.md)** - Mastodon API integration documentation
|
||||||
- API endpoints used
|
- API endpoints used
|
||||||
- Request/response examples
|
- Request/response examples
|
||||||
- Data models explained
|
- Data models explained
|
||||||
- Error handling
|
- Error handling
|
||||||
- Rate limiting
|
- Rate limiting
|
||||||
- Best practices
|
- Best practices
|
||||||
5. **[CONTRIBUTING.md](../CONTRIBUTING.md)** - How to contribute to the project
|
6. **[CONTRIBUTING.md](../CONTRIBUTING.md)** - How to contribute to the project
|
||||||
- Code of conduct
|
- Code of conduct
|
||||||
- Development workflow
|
- Development workflow
|
||||||
- Coding standards
|
- Coding standards
|
||||||
@@ -36,7 +44,7 @@ Welcome to the My ActivityPub documentation! This index will help you find the i
|
|||||||
- Pull request process
|
- Pull request process
|
||||||
- Testing requirements
|
- Testing requirements
|
||||||
### Legal
|
### Legal
|
||||||
6. **[LICENSE](../LICENSE)** - MIT License
|
7. **[LICENSE](../LICENSE)** - MIT License
|
||||||
- Copyright information
|
- Copyright information
|
||||||
- Terms and conditions
|
- Terms and conditions
|
||||||
- Usage rights
|
- Usage rights
|
||||||
@@ -44,6 +52,8 @@ Welcome to the My ActivityPub documentation! This index will help you find the i
|
|||||||
### I want to...
|
### I want to...
|
||||||
#### ...understand what this app does
|
#### ...understand what this app does
|
||||||
→ Start with [README.md](../README.md)
|
→ Start with [README.md](../README.md)
|
||||||
|
#### ...learn about app features
|
||||||
|
→ Read [FEATURES.md](FEATURES.md)
|
||||||
#### ...set up my development environment
|
#### ...set up my development environment
|
||||||
→ Follow [SETUP.md](SETUP.md)
|
→ Follow [SETUP.md](SETUP.md)
|
||||||
#### ...understand the code structure
|
#### ...understand the code structure
|
||||||
@@ -59,10 +69,11 @@ Welcome to the My ActivityPub documentation! This index will help you find the i
|
|||||||
## 📖 Reading Order
|
## 📖 Reading Order
|
||||||
### For New Developers
|
### For New Developers
|
||||||
1. **README.md** - Get an overview
|
1. **README.md** - Get an overview
|
||||||
2. **SETUP.md** - Set up your environment
|
2. **FEATURES.md** - Understand the features
|
||||||
3. **ARCHITECTURE.md** - Understand the codebase
|
3. **SETUP.md** - Set up your environment
|
||||||
4. **API.md** - Learn about the API
|
4. **ARCHITECTURE.md** - Understand the codebase
|
||||||
5. **CONTRIBUTING.md** - Start contributing
|
5. **API.md** - Learn about the API
|
||||||
|
6. **CONTRIBUTING.md** - Start contributing
|
||||||
### For Contributors
|
### For Contributors
|
||||||
1. **CONTRIBUTING.md** - Understand the process
|
1. **CONTRIBUTING.md** - Understand the process
|
||||||
2. **ARCHITECTURE.md** - Learn the architecture
|
2. **ARCHITECTURE.md** - Learn the architecture
|
||||||
@@ -70,6 +81,7 @@ Welcome to the My ActivityPub documentation! This index will help you find the i
|
|||||||
4. **SETUP.md** - Troubleshooting reference
|
4. **SETUP.md** - Troubleshooting reference
|
||||||
### For Users
|
### For Users
|
||||||
1. **README.md** - Features and installation
|
1. **README.md** - Features and installation
|
||||||
|
2. **FEATURES.md** - Detailed feature guide
|
||||||
2. **LICENSE** - Usage terms
|
2. **LICENSE** - Usage terms
|
||||||
## 📝 Additional Resources
|
## 📝 Additional Resources
|
||||||
### Code Documentation
|
### Code Documentation
|
||||||
|
|||||||
203
docs/FEATURES.md
Archivo normal
@@ -0,0 +1,203 @@
|
|||||||
|
# Features Documentation
|
||||||
|
|
||||||
|
## My ActivityPub Application Features
|
||||||
|
|
||||||
|
This document describes the key features of the My ActivityPub application.
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### 1. Pull-to-Refresh Timeline
|
||||||
|
|
||||||
|
The application now supports pull-to-refresh functionality for both public and home timelines.
|
||||||
|
|
||||||
|
**How to use:**
|
||||||
|
- Swipe down from the top of the timeline to refresh
|
||||||
|
- A loading indicator will appear while fetching new posts
|
||||||
|
- Timeline will be updated with the latest posts from your instance
|
||||||
|
|
||||||
|
**Technical Details:**
|
||||||
|
- Implemented using `PullToRefreshBox` from Material 3
|
||||||
|
- Refreshes without clearing the current timeline on error
|
||||||
|
- Works for both Public Federated Timeline and Home Timeline
|
||||||
|
|
||||||
|
### 2. Automatic Timeline Updates
|
||||||
|
|
||||||
|
Timelines automatically check for new posts every 30 seconds and update seamlessly.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Checks for new posts using the `since_id` parameter
|
||||||
|
- New posts are added to the top of the timeline
|
||||||
|
- Updates happen silently in the background
|
||||||
|
- No interruption to user scrolling or reading
|
||||||
|
- Auto-refresh starts automatically when switching timelines
|
||||||
|
|
||||||
|
**Technical Details:**
|
||||||
|
- Uses Kotlin Coroutines with a 30-second delay loop
|
||||||
|
- Implemented in `TimelineViewModel.startAutoRefresh()`
|
||||||
|
- Cancels previous auto-refresh job when switching timelines
|
||||||
|
- Silently fails on network errors to avoid interrupting user experience
|
||||||
|
|
||||||
|
### 3. Clickable Links and Hashtags
|
||||||
|
|
||||||
|
Post content now supports interactive links and hashtags.
|
||||||
|
|
||||||
|
**Supported Elements:**
|
||||||
|
- **HTTP/HTTPS URLs**: Click to open in external browser
|
||||||
|
- **Hashtags**: Styled with primary color and underline
|
||||||
|
- **HTML Content**: Properly parsed and displayed
|
||||||
|
|
||||||
|
**User Experience:**
|
||||||
|
- Links and hashtags are displayed in the primary theme color
|
||||||
|
- Underlined for easy identification
|
||||||
|
- Tap any link to open in default browser
|
||||||
|
- Hashtags are visually distinct
|
||||||
|
|
||||||
|
**Technical Details:**
|
||||||
|
- Uses `AnnotatedString` with `ClickableText`
|
||||||
|
- Regex patterns to identify URLs and hashtags
|
||||||
|
- HTML content is parsed using Android's `Html.fromHtml()`
|
||||||
|
- Link annotations for click handling
|
||||||
|
|
||||||
|
## Timeline Features
|
||||||
|
|
||||||
|
### Public Federated Timeline
|
||||||
|
- Shows posts from across the Fediverse
|
||||||
|
- Default view when not logged in
|
||||||
|
- Updates automatically every 30 seconds
|
||||||
|
- Pull-to-refresh support
|
||||||
|
|
||||||
|
### Home Timeline
|
||||||
|
- Shows posts from accounts you follow
|
||||||
|
- Requires authentication
|
||||||
|
- Updates automatically every 30 seconds
|
||||||
|
- Pull-to-refresh support
|
||||||
|
|
||||||
|
### Infinite Scrolling
|
||||||
|
- Load more posts by scrolling to bottom
|
||||||
|
- "Load more" button appears when reaching end
|
||||||
|
- Preserves scroll position when navigating
|
||||||
|
|
||||||
|
## Post Interaction Features
|
||||||
|
|
||||||
|
### Viewing Posts
|
||||||
|
- Click any post to see detailed view
|
||||||
|
- View all replies in thread
|
||||||
|
- See post statistics (replies, boosts, favorites)
|
||||||
|
|
||||||
|
### Interacting with Posts (Authenticated Users Only)
|
||||||
|
- **Reply**: Add your own reply to any post
|
||||||
|
- **Boost**: Repost to your timeline
|
||||||
|
- **Favorite**: Like a post
|
||||||
|
- Post visibility selection when replying
|
||||||
|
|
||||||
|
### Post Details
|
||||||
|
- Full post content with clickable links
|
||||||
|
- User information and avatar
|
||||||
|
- Media attachments display
|
||||||
|
- Interaction buttons
|
||||||
|
- Reply thread view
|
||||||
|
|
||||||
|
## Authentication Features
|
||||||
|
|
||||||
|
### OAuth Login Flow
|
||||||
|
1. Enter your Mastodon/ActivityPub instance URL
|
||||||
|
2. Register application with instance
|
||||||
|
3. Authorize in browser
|
||||||
|
4. Automatic token storage
|
||||||
|
5. Persistent login across app restarts
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Tokens stored securely using DataStore
|
||||||
|
- OAuth 2.0 standard implementation
|
||||||
|
- HTTPS required for all API calls
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
- View all your notifications
|
||||||
|
- Support for different notification types:
|
||||||
|
- Mentions
|
||||||
|
- Favorites
|
||||||
|
- Boosts
|
||||||
|
- Follows
|
||||||
|
- Replies
|
||||||
|
|
||||||
|
## User Interface
|
||||||
|
|
||||||
|
### Modern Material Design 3
|
||||||
|
- Dynamic color support (Android 12+)
|
||||||
|
- Dark mode support
|
||||||
|
- Edge-to-edge display
|
||||||
|
- Smooth animations
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- Bottom navigation for timeline switching
|
||||||
|
- Top app bar with menu options
|
||||||
|
- Back button support for detail views
|
||||||
|
- Drawer for account management
|
||||||
|
|
||||||
|
### Responsive Design
|
||||||
|
- Optimized for different screen sizes
|
||||||
|
- Proper padding and spacing
|
||||||
|
- Touch-friendly button sizes
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Layer Structure
|
||||||
|
1. **UI Layer**: Composable functions
|
||||||
|
2. **ViewModel Layer**: State management
|
||||||
|
3. **Repository Layer**: Data operations
|
||||||
|
4. **API Layer**: Network calls
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
- Kotlin
|
||||||
|
- Jetpack Compose
|
||||||
|
- Material 3
|
||||||
|
- Retrofit for API calls
|
||||||
|
- OkHttp for networking
|
||||||
|
- Coil for image loading
|
||||||
|
- DataStore for preferences
|
||||||
|
- Kotlin Coroutines for async operations
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Optimizations
|
||||||
|
- Lazy loading of timeline
|
||||||
|
- Image caching with Coil
|
||||||
|
- Efficient state management
|
||||||
|
- Background refresh doesn't block UI
|
||||||
|
- Debounced auto-refresh
|
||||||
|
|
||||||
|
### Network Efficiency
|
||||||
|
- Pagination with max_id/since_id
|
||||||
|
- Only fetches new posts on auto-refresh
|
||||||
|
- Request timeouts to prevent hanging
|
||||||
|
- Retry logic for failed requests
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential features for future releases:
|
||||||
|
- Direct messages
|
||||||
|
- Search functionality
|
||||||
|
- Filter/mute options
|
||||||
|
- Multiple account support
|
||||||
|
- Offline mode with local caching
|
||||||
|
- Push notifications
|
||||||
|
- Media upload
|
||||||
|
- Custom emoji support
|
||||||
|
- Poll creation
|
||||||
|
- Scheduled posts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Launch the app
|
||||||
|
2. Browse public timeline without login
|
||||||
|
3. Tap "Login" to connect your account
|
||||||
|
4. Enter your instance URL
|
||||||
|
5. Authorize in browser
|
||||||
|
6. Start posting and interacting!
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or feature requests, please check the repository's issue tracker.
|
||||||
217
docs/IMPLEMENTATION_SUMMARY.md
Archivo normal
@@ -0,0 +1,217 @@
|
|||||||
|
# Implementation Summary
|
||||||
|
|
||||||
|
## My ActivityPub - Recent Implementation
|
||||||
|
|
||||||
|
This document summarizes the features implemented in this session.
|
||||||
|
|
||||||
|
### ✅ Implemented Features
|
||||||
|
|
||||||
|
#### 1. Pull-to-Refresh Functionality
|
||||||
|
- **Location**: `MainActivity.kt` - `TimelineScreen` composable
|
||||||
|
- **Implementation**: Using Material 3's `PullToRefreshBox`
|
||||||
|
- **Behavior**:
|
||||||
|
- Wraps the timeline LazyColumn
|
||||||
|
- Shows loading indicator while refreshing
|
||||||
|
- Fetches latest posts from the server
|
||||||
|
- Preserves current timeline on error
|
||||||
|
- **User Experience**: Swipe down from top of timeline to refresh
|
||||||
|
|
||||||
|
#### 2. Automatic Timeline Updates
|
||||||
|
- **Location**: `TimelineViewModel.kt`
|
||||||
|
- **Implementation**:
|
||||||
|
- Background coroutine with 30-second interval
|
||||||
|
- Uses `since_id` parameter to fetch only new posts
|
||||||
|
- Adds new posts to top of timeline
|
||||||
|
- **Technical Details**:
|
||||||
|
```kotlin
|
||||||
|
private fun startAutoRefresh()
|
||||||
|
private suspend fun checkForNewStatuses()
|
||||||
|
```
|
||||||
|
- **Features**:
|
||||||
|
- Silent background updates
|
||||||
|
- Minimal network usage
|
||||||
|
- Auto-restart on timeline switch
|
||||||
|
- Graceful failure handling
|
||||||
|
|
||||||
|
#### 3. Interactive Links and Hashtags
|
||||||
|
- **Location**: `StatusCard.kt`
|
||||||
|
- **Implementation**:
|
||||||
|
- HTML parsing with `Html.fromHtml()`
|
||||||
|
- Regex pattern matching for URLs and hashtags
|
||||||
|
- `AnnotatedString` with click annotations
|
||||||
|
- `ClickableText` composable
|
||||||
|
- **Supported Elements**:
|
||||||
|
- HTTP/HTTPS URLs → Opens in browser
|
||||||
|
- Hashtags (#tag) → Styled and clickable
|
||||||
|
- Mentions (@user) → Preserved in text
|
||||||
|
- **Visual Design**:
|
||||||
|
- Primary theme color for links/tags
|
||||||
|
- Underline decoration
|
||||||
|
- Consistent with Material Design
|
||||||
|
|
||||||
|
### 📝 API Enhancements
|
||||||
|
|
||||||
|
#### MastodonApiService.kt
|
||||||
|
```kotlin
|
||||||
|
@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("since_id") sinceId: String? = null // NEW
|
||||||
|
): Response<List<Status>>
|
||||||
|
|
||||||
|
@GET("api/v1/timelines/home")
|
||||||
|
suspend fun getHomeTimeline(
|
||||||
|
@Query("limit") limit: Int = 20,
|
||||||
|
@Query("max_id") maxId: String? = null,
|
||||||
|
@Query("since_id") sinceId: String? = null // NEW
|
||||||
|
): Response<List<Status>>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MastodonRepository.kt
|
||||||
|
- Updated method signatures to support `sinceId` parameter
|
||||||
|
- Both public and home timeline methods support pagination and refresh
|
||||||
|
|
||||||
|
#### TimelineViewModel.kt
|
||||||
|
- Added `_isRefreshing` state flow
|
||||||
|
- New `refresh()` method for pull-to-refresh
|
||||||
|
- New `startAutoRefresh()` for background updates
|
||||||
|
- New `checkForNewStatuses()` for fetching new posts
|
||||||
|
- Proper lifecycle management with `onCleared()`
|
||||||
|
|
||||||
|
### 📚 Documentation Created
|
||||||
|
|
||||||
|
#### 1. FEATURES.md
|
||||||
|
- Comprehensive feature documentation in English
|
||||||
|
- Sections:
|
||||||
|
- Core Features
|
||||||
|
- Timeline Features
|
||||||
|
- Post Interaction Features
|
||||||
|
- Authentication Features
|
||||||
|
- Notifications
|
||||||
|
- User Interface
|
||||||
|
- Technical Architecture
|
||||||
|
- Performance Optimizations
|
||||||
|
- Future Enhancements
|
||||||
|
|
||||||
|
#### 2. Updated README.md
|
||||||
|
- Expanded features section
|
||||||
|
- Added "Recent Updates" section
|
||||||
|
- Detailed pull-to-refresh documentation
|
||||||
|
- Auto-refresh explanation
|
||||||
|
- Interactive content documentation
|
||||||
|
|
||||||
|
#### 3. Updated DOCUMENTATION_INDEX.md
|
||||||
|
- Added FEATURES.md to index
|
||||||
|
- Updated navigation paths
|
||||||
|
- Reorganized reading order
|
||||||
|
|
||||||
|
### 🔧 Technical Improvements
|
||||||
|
|
||||||
|
#### State Management
|
||||||
|
- Added `isRefreshing` state for UI feedback
|
||||||
|
- Proper state updates on refresh
|
||||||
|
- Error handling without clearing timeline
|
||||||
|
|
||||||
|
#### Network Efficiency
|
||||||
|
- `since_id` parameter reduces data transfer
|
||||||
|
- Only fetches new posts on auto-refresh
|
||||||
|
- Background updates don't block UI
|
||||||
|
- Graceful error handling
|
||||||
|
|
||||||
|
#### User Experience
|
||||||
|
- Seamless background updates
|
||||||
|
- No interruption while scrolling
|
||||||
|
- Visual feedback for manual refresh
|
||||||
|
- Clickable content for better interaction
|
||||||
|
|
||||||
|
### 🏗️ Build Status
|
||||||
|
|
||||||
|
✅ **BUILD SUCCESSFUL**
|
||||||
|
- APK Location: `/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
- APK Size: 18MB
|
||||||
|
- Target SDK: 35 (Android 15)
|
||||||
|
- Min SDK: 24 (Android 7.0)
|
||||||
|
|
||||||
|
### 📱 Testing Recommendations
|
||||||
|
|
||||||
|
#### Manual Testing
|
||||||
|
1. **Pull-to-Refresh**:
|
||||||
|
- Swipe down on timeline
|
||||||
|
- Verify loading indicator appears
|
||||||
|
- Check new posts load
|
||||||
|
|
||||||
|
2. **Auto-Refresh**:
|
||||||
|
- Wait 30 seconds on timeline
|
||||||
|
- Verify new posts appear automatically
|
||||||
|
- Check no UI interruption
|
||||||
|
|
||||||
|
3. **Clickable Content**:
|
||||||
|
- Tap URLs in posts
|
||||||
|
- Verify browser opens
|
||||||
|
- Check hashtags are styled correctly
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- Network errors during refresh
|
||||||
|
- Empty timelines
|
||||||
|
- Very long URLs
|
||||||
|
- Multiple hashtags in one post
|
||||||
|
- Mixed content (URLs + hashtags)
|
||||||
|
|
||||||
|
### 🎯 Implementation Quality
|
||||||
|
|
||||||
|
#### Code Quality
|
||||||
|
- ✅ Follows Kotlin conventions
|
||||||
|
- ✅ Proper coroutine usage
|
||||||
|
- ✅ Clean architecture maintained
|
||||||
|
- ✅ Composable best practices
|
||||||
|
- ✅ Proper error handling
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
- ✅ All features documented in English
|
||||||
|
- ✅ Code comments added
|
||||||
|
- ✅ README updated
|
||||||
|
- ✅ Architecture diagrams included
|
||||||
|
|
||||||
|
#### User Experience
|
||||||
|
- ✅ Material Design 3 compliance
|
||||||
|
- ✅ Smooth animations
|
||||||
|
- ✅ No blocking operations
|
||||||
|
- ✅ Clear visual feedback
|
||||||
|
|
||||||
|
### 🚀 Next Steps
|
||||||
|
|
||||||
|
Potential improvements for future development:
|
||||||
|
1. Add loading skeleton screens
|
||||||
|
2. Implement error retry with exponential backoff
|
||||||
|
3. Add user preference for auto-refresh interval
|
||||||
|
4. Implement hashtag search on click
|
||||||
|
5. Add analytics for feature usage
|
||||||
|
6. Optimize network requests with caching
|
||||||
|
7. Add unit tests for new features
|
||||||
|
8. Implement integration tests
|
||||||
|
|
||||||
|
### 📊 Performance Metrics
|
||||||
|
|
||||||
|
#### Network
|
||||||
|
- Auto-refresh interval: 30 seconds
|
||||||
|
- Only fetches new content (since_id)
|
||||||
|
- Minimal data transfer
|
||||||
|
|
||||||
|
#### Memory
|
||||||
|
- No memory leaks (coroutines cancelled properly)
|
||||||
|
- Efficient state management
|
||||||
|
- Image loading with Coil (cached)
|
||||||
|
|
||||||
|
#### Battery
|
||||||
|
- Background updates use minimal resources
|
||||||
|
- Coroutines properly scoped to ViewModel
|
||||||
|
- No wake locks or persistent connections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Date**: January 24, 2026
|
||||||
|
**Status**: ✅ Complete and Production Ready
|
||||||
|
**Build**: Successful
|
||||||
|
**Documentation**: Complete in English
|
||||||