@@ -0,0 +1,24 @@
|
||||
package com.manalejandro.myactivitypub
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.manalejandro.myactivitypub", appContext.packageName)
|
||||
}
|
||||
}
|
||||
41
app/src/main/AndroidManifest.xml
Archivo normal
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MyActivityPub"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.MyActivityPub"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- OAuth callback -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="myactivitypub"
|
||||
android:host="oauth" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 25 KiB |
605
app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt
Archivo normal
@@ -0,0 +1,605 @@
|
||||
package com.manalejandro.myactivitypub
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
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.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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.manalejandro.myactivitypub.data.api.MastodonApiService
|
||||
import com.manalejandro.myactivitypub.data.repository.AuthRepository
|
||||
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
|
||||
import com.manalejandro.myactivitypub.ui.components.StatusCard
|
||||
import com.manalejandro.myactivitypub.ui.screens.LoginScreen
|
||||
import com.manalejandro.myactivitypub.ui.theme.MyActivityPubTheme
|
||||
import com.manalejandro.myactivitypub.ui.viewmodel.AuthState
|
||||
import com.manalejandro.myactivitypub.ui.viewmodel.AuthViewModel
|
||||
import com.manalejandro.myactivitypub.ui.viewmodel.InteractionState
|
||||
import com.manalejandro.myactivitypub.ui.viewmodel.TimelineType
|
||||
import com.manalejandro.myactivitypub.ui.viewmodel.TimelineUiState
|
||||
import com.manalejandro.myactivitypub.ui.viewmodel.TimelineViewModel
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Main Activity for My ActivityPub app
|
||||
* Supports OAuth login and displays public/home timelines
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
MyActivityPubTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MyActivityPubApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MyActivityPubApp() {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
|
||||
// Extract OAuth code from intent
|
||||
var authCode by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Check for OAuth code in intent
|
||||
LaunchedEffect(activity?.intent) {
|
||||
activity?.intent?.data?.let { uri ->
|
||||
println("MainActivity: Received intent with URI: $uri")
|
||||
if (uri.scheme == "myactivitypub" && uri.host == "oauth") {
|
||||
val code = uri.getQueryParameter("code")
|
||||
println("MainActivity: Extracted OAuth code: ${code?.take(10)}...")
|
||||
authCode = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track if we've processed the auth code
|
||||
var hasProcessedAuthCode by remember { mutableStateOf(false) }
|
||||
|
||||
// Setup API service creator
|
||||
val createApiService: (String, String?) -> MastodonApiService = remember {
|
||||
{ baseUrl, token ->
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
|
||||
val authInterceptor = Interceptor { chain ->
|
||||
val request = if (token != null) {
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://$baseUrl/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(MastodonApiService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup repositories - Use remember with key to create singleton
|
||||
val authRepository = remember(context) {
|
||||
AuthRepository(context) { instance ->
|
||||
createApiService(instance, null)
|
||||
}
|
||||
}
|
||||
|
||||
val authViewModel: AuthViewModel = viewModel {
|
||||
AuthViewModel(authRepository)
|
||||
}
|
||||
|
||||
val userSession by authViewModel.userSession.collectAsState()
|
||||
val authState by authViewModel.authState.collectAsState()
|
||||
|
||||
// Log state changes
|
||||
LaunchedEffect(userSession) {
|
||||
println("MainActivity: userSession updated - instance: ${userSession?.instance}, hasToken: ${userSession?.accessToken != null}")
|
||||
}
|
||||
|
||||
LaunchedEffect(authState) {
|
||||
println("MainActivity: authState = ${authState::class.simpleName}")
|
||||
}
|
||||
|
||||
// Handle OAuth code - only process once
|
||||
LaunchedEffect(authCode, hasProcessedAuthCode) {
|
||||
val code = authCode // Create local copy for smart cast
|
||||
if (code != null && !hasProcessedAuthCode) {
|
||||
println("MainActivity: Processing OAuth code")
|
||||
hasProcessedAuthCode = true
|
||||
authViewModel.completeLogin(code)
|
||||
} else if (code != null) {
|
||||
println("MainActivity: OAuth code already processed")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle authorization redirect
|
||||
LaunchedEffect(authState) {
|
||||
if (authState is AuthState.NeedsAuthorization) {
|
||||
val authUrl = (authState as AuthState.NeedsAuthorization).authUrl
|
||||
val intent = CustomTabsIntent.Builder().build()
|
||||
intent.launchUrl(context, Uri.parse(authUrl))
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which screen to show
|
||||
var showLoginScreen by remember { mutableStateOf(false) }
|
||||
|
||||
// Reset login screen when successfully authenticated
|
||||
LaunchedEffect(userSession) {
|
||||
if (userSession != null) {
|
||||
showLoginScreen = false
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
showLoginScreen && userSession == null -> {
|
||||
LoginScreen(
|
||||
onLoginClick = { instance ->
|
||||
authViewModel.startLogin(instance)
|
||||
},
|
||||
onContinueAsGuest = {
|
||||
showLoginScreen = false
|
||||
},
|
||||
isLoading = authState is AuthState.LoggingIn,
|
||||
errorMessage = if (authState is AuthState.Error) (authState as AuthState.Error).message else null
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val instance = userSession?.instance ?: "mastodon.social"
|
||||
val token = userSession?.accessToken
|
||||
|
||||
val apiService = remember(instance, token) {
|
||||
createApiService(instance, token)
|
||||
}
|
||||
|
||||
val repository = remember(apiService) {
|
||||
MastodonRepository(apiService)
|
||||
}
|
||||
|
||||
val timelineViewModel: TimelineViewModel = viewModel(key = "$instance-$token") {
|
||||
TimelineViewModel(repository)
|
||||
}
|
||||
|
||||
TimelineScreen(
|
||||
viewModel = timelineViewModel,
|
||||
userSession = userSession,
|
||||
createApiService = createApiService,
|
||||
onLoginClick = { showLoginScreen = true },
|
||||
onLogoutClick = { authViewModel.logout() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TimelineScreen(
|
||||
viewModel: TimelineViewModel,
|
||||
userSession: com.manalejandro.myactivitypub.data.models.UserSession?,
|
||||
createApiService: (String, String?) -> MastodonApiService,
|
||||
onLoginClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val timelineType by viewModel.timelineType.collectAsState()
|
||||
val interactionState by viewModel.interactionState.collectAsState()
|
||||
val isLoadingMore by viewModel.isLoadingMore.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) }
|
||||
var selectedStatus by remember { mutableStateOf<com.manalejandro.myactivitypub.data.models.Status?>(null) }
|
||||
|
||||
// Remember scroll state to preserve position when navigating
|
||||
val timelineScrollState = rememberLazyListState()
|
||||
|
||||
// Handle back button for different states
|
||||
BackHandler(enabled = selectedStatus != null) {
|
||||
selectedStatus = null
|
||||
}
|
||||
|
||||
BackHandler(enabled = showNotifications) {
|
||||
showNotifications = false
|
||||
}
|
||||
|
||||
// Handle interaction state
|
||||
LaunchedEffect(interactionState) {
|
||||
when (interactionState) {
|
||||
is InteractionState.ReplyPosted -> {
|
||||
replyingToStatus = null
|
||||
viewModel.resetInteractionState()
|
||||
}
|
||||
is InteractionState.Success -> {
|
||||
// Auto-reset after short delay
|
||||
kotlinx.coroutines.delay(500)
|
||||
viewModel.resetInteractionState()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Reply dialog
|
||||
replyingToStatus?.let { status ->
|
||||
com.manalejandro.myactivitypub.ui.components.ReplyDialog(
|
||||
status = status,
|
||||
onDismiss = { replyingToStatus = null },
|
||||
onReply = { content ->
|
||||
viewModel.postReply(content, status.id)
|
||||
},
|
||||
isPosting = interactionState is InteractionState.Processing
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
if (showNotifications) "Notifications" else "My ActivityPub",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
if (userSession != null && !showNotifications) {
|
||||
Text(
|
||||
"@${userSession.account?.username ?: "user"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (showNotifications) {
|
||||
IconButton(onClick = { showNotifications = false }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!showNotifications) {
|
||||
IconButton(onClick = { viewModel.loadTimeline() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
|
||||
if (userSession != null) {
|
||||
IconButton(onClick = { showNotifications = true }) {
|
||||
Icon(Icons.Default.Notifications, contentDescription = "Notifications")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More options")
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
if (userSession != null && !showNotifications) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Switch to ${if (timelineType == TimelineType.PUBLIC) "Home" else "Public"}") },
|
||||
onClick = {
|
||||
viewModel.switchTimeline(
|
||||
if (timelineType == TimelineType.PUBLIC) TimelineType.HOME else TimelineType.PUBLIC
|
||||
)
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (timelineType == TimelineType.PUBLIC) Icons.Default.Home else Icons.Default.Public,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (userSession != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Logout") },
|
||||
onClick = {
|
||||
onLogoutClick()
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Login") },
|
||||
onClick = {
|
||||
onLoginClick()
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
// Show status detail if selected
|
||||
if (selectedStatus != null) {
|
||||
// Create repository for status detail
|
||||
val detailApiService = remember(userSession) {
|
||||
createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken)
|
||||
}
|
||||
val detailRepository = remember(detailApiService) {
|
||||
MastodonRepository(detailApiService)
|
||||
}
|
||||
|
||||
com.manalejandro.myactivitypub.ui.screens.StatusDetailScreen(
|
||||
status = selectedStatus!!,
|
||||
repository = detailRepository,
|
||||
onBackClick = { selectedStatus = null },
|
||||
onReplyClick = { replyingToStatus = it },
|
||||
onStatusUpdated = { updatedStatus ->
|
||||
viewModel.updateStatus(updatedStatus)
|
||||
},
|
||||
isAuthenticated = userSession != null
|
||||
)
|
||||
} else if (showNotifications && userSession != null) {
|
||||
// Show notifications screen
|
||||
val notificationsViewModel: com.manalejandro.myactivitypub.ui.viewmodel.NotificationsViewModel = viewModel(key = userSession.instance) {
|
||||
com.manalejandro.myactivitypub.ui.viewmodel.NotificationsViewModel(
|
||||
com.manalejandro.myactivitypub.data.repository.MastodonRepository(
|
||||
createApiService(userSession.instance, userSession.accessToken)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val notificationsUiState by notificationsViewModel.uiState.collectAsState()
|
||||
|
||||
when (notificationsUiState) {
|
||||
is com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Success -> {
|
||||
val notifications = (notificationsUiState as com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Success).notifications
|
||||
com.manalejandro.myactivitypub.ui.screens.NotificationsScreen(
|
||||
notifications = notifications,
|
||||
onRefresh = { notificationsViewModel.loadNotifications() },
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
is com.manalejandro.myactivitypub.ui.viewmodel.NotificationsUiState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Error loading notifications")
|
||||
Button(onClick = { notificationsViewModel.loadNotifications() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show timeline
|
||||
when (uiState) {
|
||||
is TimelineUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Loading ${if (timelineType == TimelineType.PUBLIC) "public" else "home"} timeline...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
item {
|
||||
// Timeline type indicator
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
if (timelineType == TimelineType.PUBLIC) Icons.Default.Public else Icons.Default.Home,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (timelineType == TimelineType.PUBLIC)
|
||||
"Public Federated Timeline"
|
||||
else
|
||||
"Home Timeline",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(statuses) { status ->
|
||||
StatusCard(
|
||||
status = status,
|
||||
onStatusClick = { selectedStatus = it },
|
||||
onReplyClick = { replyingToStatus = it },
|
||||
onBoostClick = { viewModel.toggleBoost(it) },
|
||||
onFavoriteClick = { viewModel.toggleFavorite(it) },
|
||||
isAuthenticated = userSession != null
|
||||
)
|
||||
}
|
||||
|
||||
// Loading more indicator
|
||||
item {
|
||||
if (isLoadingMore) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
} else if (statuses.isNotEmpty()) {
|
||||
// Trigger to load more when this item becomes visible
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
TextButton(onClick = { viewModel.loadMore() }) {
|
||||
Text("Load more")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is TimelineUiState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Error loading timeline",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = (uiState as TimelineUiState.Error).message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = { viewModel.loadTimeline() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.manalejandro.myactivitypub.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
/**
|
||||
* Singleton DataStore instance to prevent multiple DataStores for the same file
|
||||
*/
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_prefs")
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.manalejandro.myactivitypub.data.api
|
||||
|
||||
import com.manalejandro.myactivitypub.data.models.*
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
/**
|
||||
* Mastodon API service interface
|
||||
* Based on Mastodon API v1 and ActivityPub endpoints
|
||||
* Reference: https://docs.joinmastodon.org/api/
|
||||
*/
|
||||
interface MastodonApiService {
|
||||
|
||||
/**
|
||||
* Register an OAuth application
|
||||
*/
|
||||
@POST("api/v1/apps")
|
||||
@FormUrlEncoded
|
||||
suspend fun registerApp(
|
||||
@Field("client_name") clientName: String,
|
||||
@Field("redirect_uris") redirectUris: String,
|
||||
@Field("scopes") scopes: String,
|
||||
@Field("website") website: String? = null
|
||||
): Response<AppRegistration>
|
||||
|
||||
/**
|
||||
* Obtain OAuth token
|
||||
*/
|
||||
@POST("oauth/token")
|
||||
@FormUrlEncoded
|
||||
suspend fun obtainToken(
|
||||
@Field("client_id") clientId: String,
|
||||
@Field("client_secret") clientSecret: String,
|
||||
@Field("redirect_uri") redirectUri: String,
|
||||
@Field("grant_type") grantType: String = "authorization_code",
|
||||
@Field("code") code: String,
|
||||
@Field("scope") scope: String
|
||||
): Response<TokenResponse>
|
||||
|
||||
/**
|
||||
* Verify account credentials (requires authentication)
|
||||
*/
|
||||
@GET("api/v1/accounts/verify_credentials")
|
||||
suspend fun verifyCredentials(): Response<Account>
|
||||
|
||||
/**
|
||||
* Get public timeline
|
||||
* @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)
|
||||
*/
|
||||
@GET("api/v1/timelines/public")
|
||||
suspend fun getPublicTimeline(
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("local") local: Boolean = false,
|
||||
@Query("max_id") maxId: String? = null
|
||||
): Response<List<Status>>
|
||||
|
||||
/**
|
||||
* Get home timeline (requires authentication)
|
||||
* @param maxId Get statuses older than this ID (for pagination)
|
||||
*/
|
||||
@GET("api/v1/timelines/home")
|
||||
suspend fun getHomeTimeline(
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("max_id") maxId: String? = null
|
||||
): Response<List<Status>>
|
||||
|
||||
/**
|
||||
* Get instance information
|
||||
*/
|
||||
@GET("api/v1/instance")
|
||||
suspend fun getInstanceInfo(): Response<Instance>
|
||||
|
||||
/**
|
||||
* Get account information
|
||||
* @param accountId Account ID
|
||||
*/
|
||||
@GET("api/v1/accounts/{id}")
|
||||
suspend fun getAccount(@Path("id") accountId: String): Response<Account>
|
||||
|
||||
/**
|
||||
* Get statuses from a specific account
|
||||
* @param accountId Account ID
|
||||
* @param limit Maximum number of statuses to return
|
||||
*/
|
||||
@GET("api/v1/accounts/{id}/statuses")
|
||||
suspend fun getAccountStatuses(
|
||||
@Path("id") accountId: String,
|
||||
@Query("limit") limit: Int = 20
|
||||
): Response<List<Status>>
|
||||
|
||||
/**
|
||||
* Favorite a status (like)
|
||||
*/
|
||||
@POST("api/v1/statuses/{id}/favourite")
|
||||
suspend fun favoriteStatus(@Path("id") statusId: String): Response<Status>
|
||||
|
||||
/**
|
||||
* Unfavorite a status (unlike)
|
||||
*/
|
||||
@POST("api/v1/statuses/{id}/unfavourite")
|
||||
suspend fun unfavoriteStatus(@Path("id") statusId: String): Response<Status>
|
||||
|
||||
/**
|
||||
* Boost a status (reblog)
|
||||
*/
|
||||
@POST("api/v1/statuses/{id}/reblog")
|
||||
suspend fun boostStatus(@Path("id") statusId: String): Response<Status>
|
||||
|
||||
/**
|
||||
* Unboost a status (unreblog)
|
||||
*/
|
||||
@POST("api/v1/statuses/{id}/unreblog")
|
||||
suspend fun unboostStatus(@Path("id") statusId: String): Response<Status>
|
||||
|
||||
/**
|
||||
* Post a new status (reply or new post)
|
||||
*/
|
||||
@POST("api/v1/statuses")
|
||||
@FormUrlEncoded
|
||||
suspend fun postStatus(
|
||||
@Field("status") status: String,
|
||||
@Field("in_reply_to_id") inReplyToId: String? = null,
|
||||
@Field("visibility") visibility: String = "public"
|
||||
): Response<Status>
|
||||
|
||||
/**
|
||||
* Get notifications
|
||||
*/
|
||||
@GET("api/v1/notifications")
|
||||
suspend fun getNotifications(
|
||||
@Query("limit") limit: Int = 20,
|
||||
@Query("exclude_types[]") excludeTypes: List<String>? = null
|
||||
): Response<List<Notification>>
|
||||
|
||||
/**
|
||||
* Get status context (ancestors and descendants/replies)
|
||||
*/
|
||||
@GET("api/v1/statuses/{id}/context")
|
||||
suspend fun getStatusContext(@Path("id") statusId: String): Response<StatusContext>
|
||||
}
|
||||
22
app/src/main/java/com/manalejandro/myactivitypub/data/models/Account.kt
Archivo normal
@@ -0,0 +1,22 @@
|
||||
package com.manalejandro.myactivitypub.data.models
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Represents a user account in Mastodon/ActivityPub
|
||||
*/
|
||||
data class Account(
|
||||
val id: String,
|
||||
val username: String,
|
||||
@SerializedName("display_name") val displayName: String,
|
||||
val avatar: String,
|
||||
val header: String = "",
|
||||
val note: String = "",
|
||||
@SerializedName("followers_count") val followersCount: Int = 0,
|
||||
@SerializedName("following_count") val followingCount: Int = 0,
|
||||
@SerializedName("statuses_count") val statusesCount: Int = 0,
|
||||
val url: String? = null,
|
||||
val acct: String = username,
|
||||
val bot: Boolean = false,
|
||||
val locked: Boolean = false
|
||||
)
|
||||
33
app/src/main/java/com/manalejandro/myactivitypub/data/models/Auth.kt
Archivo normal
@@ -0,0 +1,33 @@
|
||||
package com.manalejandro.myactivitypub.data.models
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* OAuth application registration response
|
||||
*/
|
||||
data class AppRegistration(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@SerializedName("client_id") val clientId: String,
|
||||
@SerializedName("client_secret") val clientSecret: String,
|
||||
@SerializedName("redirect_uri") val redirectUri: String = "myactivitypub://oauth"
|
||||
)
|
||||
|
||||
/**
|
||||
* OAuth token response
|
||||
*/
|
||||
data class TokenResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("token_type") val tokenType: String,
|
||||
val scope: String,
|
||||
@SerializedName("created_at") val createdAt: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* User session data
|
||||
*/
|
||||
data class UserSession(
|
||||
val instance: String,
|
||||
val accessToken: String,
|
||||
val account: Account? = null
|
||||
)
|
||||
18
app/src/main/java/com/manalejandro/myactivitypub/data/models/Instance.kt
Archivo normal
@@ -0,0 +1,18 @@
|
||||
package com.manalejandro.myactivitypub.data.models
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Instance information for Mastodon/ActivityPub servers
|
||||
*/
|
||||
data class Instance(
|
||||
val uri: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val version: String,
|
||||
val thumbnail: String? = null,
|
||||
@SerializedName("short_description") val shortDescription: String? = null,
|
||||
val email: String? = null,
|
||||
val languages: List<String> = emptyList(),
|
||||
@SerializedName("contact_account") val contactAccount: Account? = null
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.manalejandro.myactivitypub.data.models
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Represents media attachments (images, videos, etc.)
|
||||
*/
|
||||
data class MediaAttachment(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val url: String,
|
||||
@SerializedName("preview_url") val previewUrl: String? = null,
|
||||
val description: String? = null,
|
||||
@SerializedName("remote_url") val remoteUrl: String? = null
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.manalejandro.myactivitypub.data.models
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Represents a notification in Mastodon/ActivityPub
|
||||
*/
|
||||
data class Notification(
|
||||
val id: String,
|
||||
val type: String, // mention, reblog, favourite, follow, poll, follow_request, status, update
|
||||
@SerializedName("created_at") val createdAt: String,
|
||||
val account: Account,
|
||||
val status: Status? = null // Present for mention, reblog, favourite, poll, status, update
|
||||
)
|
||||
23
app/src/main/java/com/manalejandro/myactivitypub/data/models/Status.kt
Archivo normal
@@ -0,0 +1,23 @@
|
||||
package com.manalejandro.myactivitypub.data.models
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Represents a Mastodon/ActivityPub status (post/toot)
|
||||
* Based on Mastodon API v1 specification
|
||||
*/
|
||||
data class Status(
|
||||
val id: String,
|
||||
val content: String,
|
||||
@SerializedName("created_at") val createdAt: String,
|
||||
val account: Account,
|
||||
@SerializedName("media_attachments") val mediaAttachments: List<MediaAttachment> = emptyList(),
|
||||
@SerializedName("reblog") val reblog: Status? = null,
|
||||
@SerializedName("favourites_count") val favouritesCount: Int = 0,
|
||||
@SerializedName("reblogs_count") val reblogsCount: Int = 0,
|
||||
@SerializedName("replies_count") val repliesCount: Int = 0,
|
||||
val favourited: Boolean = false,
|
||||
val reblogged: Boolean = false,
|
||||
val url: String? = null,
|
||||
val visibility: String = "public"
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.manalejandro.myactivitypub.data.models
|
||||
|
||||
/**
|
||||
* Context of a status (ancestors and descendants)
|
||||
*/
|
||||
data class StatusContext(
|
||||
val ancestors: List<Status>, // Parent posts
|
||||
val descendants: List<Status> // Replies
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.manalejandro.myactivitypub.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.manalejandro.myactivitypub.data.api.MastodonApiService
|
||||
import com.manalejandro.myactivitypub.data.dataStore
|
||||
import com.manalejandro.myactivitypub.data.models.AppRegistration
|
||||
import com.manalejandro.myactivitypub.data.models.TokenResponse
|
||||
import com.manalejandro.myactivitypub.data.models.UserSession
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Repository for handling authentication operations
|
||||
*/
|
||||
class AuthRepository(
|
||||
private val context: Context,
|
||||
private val createApiService: (String) -> MastodonApiService
|
||||
) {
|
||||
|
||||
|
||||
companion object {
|
||||
private val KEY_INSTANCE = stringPreferencesKey("instance")
|
||||
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
|
||||
private val KEY_CLIENT_SECRET = stringPreferencesKey("client_secret")
|
||||
|
||||
const val REDIRECT_URI = "myactivitypub://oauth"
|
||||
const val SCOPES = "read write follow"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user session as Flow
|
||||
*/
|
||||
val userSession: Flow<UserSession?> = context.dataStore.data.map { prefs ->
|
||||
val instance = prefs[KEY_INSTANCE]
|
||||
val token = prefs[KEY_ACCESS_TOKEN]
|
||||
|
||||
if (instance != null && token != null) {
|
||||
// Load account info with proper authentication
|
||||
try {
|
||||
// Create authenticated service with token
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
val authInterceptor = okhttp3.Interceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val authenticatedService = Retrofit.Builder()
|
||||
.baseUrl("https://$instance/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(MastodonApiService::class.java)
|
||||
|
||||
val response = authenticatedService.verifyCredentials()
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
println("AuthRepository: Account loaded: @${response.body()?.username}")
|
||||
UserSession(instance, token, response.body())
|
||||
} else {
|
||||
println("AuthRepository: Failed to load account: ${response.code()}")
|
||||
UserSession(instance, token, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("AuthRepository: Error loading account: ${e.message}")
|
||||
e.printStackTrace()
|
||||
UserSession(instance, token, null)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Register OAuth application with instance
|
||||
*/
|
||||
suspend fun registerApp(instance: String): Result<AppRegistration> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val apiService = createApiService(instance)
|
||||
val response = apiService.registerApp(
|
||||
clientName = "My ActivityPub",
|
||||
redirectUris = REDIRECT_URI,
|
||||
scopes = SCOPES,
|
||||
website = "https://github.com/yourusername/myactivitypub"
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val app = response.body()!!
|
||||
// Save app credentials
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_INSTANCE] = instance
|
||||
prefs[KEY_CLIENT_ID] = app.clientId
|
||||
prefs[KEY_CLIENT_SECRET] = app.clientSecret
|
||||
}
|
||||
Result.success(app)
|
||||
} else {
|
||||
Result.failure(Exception("Failed to register app: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth authorization URL
|
||||
*/
|
||||
suspend fun getAuthorizationUrl(instance: String): String {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val clientId = prefs[KEY_CLIENT_ID] ?: ""
|
||||
|
||||
return "https://$instance/oauth/authorize?" +
|
||||
"client_id=$clientId&" +
|
||||
"scope=$SCOPES&" +
|
||||
"redirect_uri=$REDIRECT_URI&" +
|
||||
"response_type=code"
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
suspend fun obtainToken(code: String): Result<TokenResponse> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val instance = prefs[KEY_INSTANCE] ?: return@withContext Result.failure(
|
||||
Exception("No instance configured")
|
||||
)
|
||||
val clientId = prefs[KEY_CLIENT_ID] ?: return@withContext Result.failure(
|
||||
Exception("No client ID")
|
||||
)
|
||||
val clientSecret = prefs[KEY_CLIENT_SECRET] ?: return@withContext Result.failure(
|
||||
Exception("No client secret")
|
||||
)
|
||||
|
||||
println("AuthRepository: Obtaining token for instance: $instance")
|
||||
val apiService = createApiService(instance)
|
||||
val response = apiService.obtainToken(
|
||||
clientId = clientId,
|
||||
clientSecret = clientSecret,
|
||||
redirectUri = REDIRECT_URI,
|
||||
grantType = "authorization_code",
|
||||
code = code,
|
||||
scope = SCOPES
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val token = response.body()!!
|
||||
println("AuthRepository: Token obtained successfully")
|
||||
// Save access token
|
||||
context.dataStore.edit {
|
||||
it[KEY_ACCESS_TOKEN] = token.accessToken
|
||||
}
|
||||
println("AuthRepository: Token saved to DataStore")
|
||||
Result.success(token)
|
||||
} else {
|
||||
println("AuthRepository: Failed to obtain token: ${response.code()}")
|
||||
Result.failure(Exception("Failed to obtain token: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("AuthRepository: Exception obtaining token: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify credentials and get account info
|
||||
*/
|
||||
suspend fun verifyCredentials(apiService: MastodonApiService): Result<UserSession> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.verifyCredentials()
|
||||
val prefs = context.dataStore.data.first()
|
||||
val instance = prefs[KEY_INSTANCE] ?: ""
|
||||
val token = prefs[KEY_ACCESS_TOKEN] ?: ""
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(UserSession(instance, token, response.body()))
|
||||
} else {
|
||||
Result.failure(Exception("Failed to verify credentials"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
suspend fun logout() {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(KEY_ACCESS_TOKEN)
|
||||
prefs.remove(KEY_CLIENT_ID)
|
||||
prefs.remove(KEY_CLIENT_SECRET)
|
||||
prefs.remove(KEY_INSTANCE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.manalejandro.myactivitypub.data.repository
|
||||
|
||||
import com.manalejandro.myactivitypub.data.api.MastodonApiService
|
||||
import com.manalejandro.myactivitypub.data.models.Instance
|
||||
import com.manalejandro.myactivitypub.data.models.Notification
|
||||
import com.manalejandro.myactivitypub.data.models.Status
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Repository for handling Mastodon API operations
|
||||
* Provides a clean API for the ViewModel layer
|
||||
*/
|
||||
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>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.getPublicTimeline(limit, local, maxId)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body() ?: emptyList())
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the home timeline (requires authentication)
|
||||
*/
|
||||
suspend fun getHomeTimeline(limit: Int = 20, maxId: String? = null): Result<List<Status>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.getHomeTimeline(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch instance information
|
||||
*/
|
||||
suspend fun getInstanceInfo(): Result<Instance> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.getInstanceInfo()
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorite a status
|
||||
*/
|
||||
suspend fun favoriteStatus(statusId: String): Result<Status> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.favoriteStatus(statusId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfavorite a status
|
||||
*/
|
||||
suspend fun unfavoriteStatus(statusId: String): Result<Status> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.unfavoriteStatus(statusId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boost a status
|
||||
*/
|
||||
suspend fun boostStatus(statusId: String): Result<Status> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.boostStatus(statusId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unboost a status
|
||||
*/
|
||||
suspend fun unboostStatus(statusId: String): Result<Status> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.unboostStatus(statusId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a new status or reply
|
||||
*/
|
||||
suspend fun postStatus(content: String, inReplyToId: String? = null): Result<Status> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.postStatus(content, inReplyToId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications
|
||||
*/
|
||||
suspend fun getNotifications(limit: Int = 20): Result<List<Notification>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.getNotifications(limit)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body() ?: emptyList())
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status context (replies)
|
||||
*/
|
||||
suspend fun getStatusContext(statusId: String): Result<com.manalejandro.myactivitypub.data.models.StatusContext> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiService.getStatusContext(statusId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()} - ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.manalejandro.myactivitypub.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.manalejandro.myactivitypub.data.models.Status
|
||||
|
||||
/**
|
||||
* Dialog for composing a reply to a status
|
||||
*/
|
||||
@Composable
|
||||
fun ReplyDialog(
|
||||
status: Status,
|
||||
onDismiss: () -> Unit,
|
||||
onReply: (String) -> Unit,
|
||||
isPosting: Boolean = false
|
||||
) {
|
||||
var replyText by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("Reply to @${status.account.acct}")
|
||||
},
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Show original toot excerpt
|
||||
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))
|
||||
|
||||
// Reply text field
|
||||
OutlinedTextField(
|
||||
value = replyText,
|
||||
onValueChange = { replyText = it },
|
||||
label = { Text("Your reply") },
|
||||
placeholder = { Text("Write your reply here...") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 100.dp),
|
||||
maxLines = 5,
|
||||
enabled = !isPosting
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (replyText.isNotBlank()) {
|
||||
onReply(replyText)
|
||||
}
|
||||
},
|
||||
enabled = replyText.isNotBlank() && !isPosting
|
||||
) {
|
||||
if (isPosting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Reply")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
enabled = !isPosting
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
226
app/src/main/java/com/manalejandro/myactivitypub/ui/components/StatusCard.kt
Archivo normal
@@ -0,0 +1,226 @@
|
||||
package com.manalejandro.myactivitypub.ui.components
|
||||
|
||||
import android.text.Html
|
||||
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.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.manalejandro.myactivitypub.data.models.Status
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Card component for displaying a single status/post
|
||||
*/
|
||||
@Composable
|
||||
fun StatusCard(
|
||||
status: Status,
|
||||
modifier: Modifier = Modifier,
|
||||
onReplyClick: (Status) -> Unit = {},
|
||||
onBoostClick: (Status) -> Unit = {},
|
||||
onFavoriteClick: (Status) -> Unit = {},
|
||||
onStatusClick: (Status) -> Unit = {},
|
||||
isAuthenticated: Boolean = false
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable { onStatusClick(status) },
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// User info header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
model = status.account.avatar,
|
||||
contentDescription = "Avatar of ${status.account.username}",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = status.account.displayName.ifEmpty { status.account.username },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "@${status.account.acct}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = formatTimestamp(status.createdAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// Media attachments
|
||||
if (status.mediaAttachments.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
status.mediaAttachments.forEach { media ->
|
||||
AsyncImage(
|
||||
model = media.url,
|
||||
contentDescription = media.description ?: "Media attachment",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
// Reply button
|
||||
ActionButton(
|
||||
icon = Icons.AutoMirrored.Filled.Reply,
|
||||
count = status.repliesCount,
|
||||
contentDescription = "Reply",
|
||||
onClick = { if (isAuthenticated) onReplyClick(status) },
|
||||
enabled = isAuthenticated
|
||||
)
|
||||
|
||||
// Boost button
|
||||
ActionButton(
|
||||
icon = Icons.Default.Repeat,
|
||||
count = status.reblogsCount,
|
||||
contentDescription = "Boost",
|
||||
onClick = { if (isAuthenticated) onBoostClick(status) },
|
||||
isActive = status.reblogged,
|
||||
enabled = isAuthenticated
|
||||
)
|
||||
|
||||
// Favorite button
|
||||
ActionButton(
|
||||
icon = if (status.favourited) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
count = status.favouritesCount,
|
||||
contentDescription = "Favorite",
|
||||
onClick = { if (isAuthenticated) onFavoriteClick(status) },
|
||||
isActive = status.favourited,
|
||||
enabled = isAuthenticated
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action button for status interactions
|
||||
*/
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
count: Int,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
isActive: Boolean = false,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
isActive -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
if (count > 0) {
|
||||
Text(
|
||||
text = formatCount(count),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
isActive -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time
|
||||
*/
|
||||
private fun formatTimestamp(timestamp: String): String {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(timestamp)
|
||||
val now = Date()
|
||||
val diff = now.time - (date?.time ?: 0)
|
||||
|
||||
when {
|
||||
diff < 60000 -> "now"
|
||||
diff < 3600000 -> "${diff / 60000}m"
|
||||
diff < 86400000 -> "${diff / 3600000}h"
|
||||
else -> "${diff / 86400000}d"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers with K, M suffixes
|
||||
*/
|
||||
private fun formatCount(count: Int): String {
|
||||
return when {
|
||||
count >= 1000000 -> "${count / 1000000}M"
|
||||
count >= 1000 -> "${count / 1000}K"
|
||||
else -> count.toString()
|
||||
}
|
||||
}
|
||||
160
app/src/main/java/com/manalejandro/myactivitypub/ui/screens/LoginScreen.kt
Archivo normal
@@ -0,0 +1,160 @@
|
||||
package com.manalejandro.myactivitypub.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Login screen for entering Mastodon instance
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginClick: (String) -> Unit,
|
||||
onContinueAsGuest: () -> Unit,
|
||||
isLoading: Boolean = false,
|
||||
errorMessage: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var instance by remember { mutableStateOf("") }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Login to Mastodon") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Logo or icon
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Login,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = "Welcome to My ActivityPub",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Connect to your Mastodon instance",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Instance input
|
||||
OutlinedTextField(
|
||||
value = instance,
|
||||
onValueChange = { instance = it.trim() },
|
||||
label = { Text("Instance domain") },
|
||||
placeholder = { Text("mastodon.social") },
|
||||
singleLine = true,
|
||||
enabled = !isLoading,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (instance.isNotBlank()) {
|
||||
onLoginClick(instance)
|
||||
}
|
||||
}
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter just the domain (e.g., mastodon.social)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Error message
|
||||
if (errorMessage != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Login button
|
||||
Button(
|
||||
onClick = { onLoginClick(instance) },
|
||||
enabled = instance.isNotBlank() && !isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Login")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Continue as guest button
|
||||
TextButton(
|
||||
onClick = onContinueAsGuest,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Continue as Guest")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "As a guest, you can browse the public federated timeline from mastodon.social",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.manalejandro.myactivitypub.data.models.Notification
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Screen for displaying notifications
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
notifications: List<Notification>,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(notifications) { notification ->
|
||||
NotificationItem(notification = notification)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single notification item
|
||||
*/
|
||||
@Composable
|
||||
private fun NotificationItem(notification: Notification) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Notification icon
|
||||
Icon(
|
||||
imageVector = getNotificationIcon(notification.type),
|
||||
contentDescription = notification.type,
|
||||
tint = getNotificationColor(notification.type),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// User info
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AsyncImage(
|
||||
model = notification.account.avatar,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = notification.account.displayName.ifEmpty { notification.account.username },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = getNotificationText(notification.type),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = formatTimestamp(notification.createdAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Status content if present
|
||||
notification.status?.let { status ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = android.text.Html.fromHtml(
|
||||
status.content,
|
||||
android.text.Html.FROM_HTML_MODE_COMPACT
|
||||
).toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
maxLines = 3
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for notification type
|
||||
*/
|
||||
@Composable
|
||||
private fun getNotificationIcon(type: String) = when (type) {
|
||||
"mention" -> Icons.Default.AlternateEmail
|
||||
"reblog" -> Icons.Default.Repeat
|
||||
"favourite" -> Icons.Default.Favorite
|
||||
"follow" -> Icons.Default.PersonAdd
|
||||
"poll" -> Icons.Default.Poll
|
||||
"status" -> Icons.Default.Campaign
|
||||
else -> Icons.Default.Notifications
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for notification type
|
||||
*/
|
||||
@Composable
|
||||
private fun getNotificationColor(type: String) = when (type) {
|
||||
"mention" -> MaterialTheme.colorScheme.primary
|
||||
"reblog" -> MaterialTheme.colorScheme.secondary
|
||||
"favourite" -> MaterialTheme.colorScheme.error
|
||||
"follow" -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text description for notification type
|
||||
*/
|
||||
private fun getNotificationText(type: String) = when (type) {
|
||||
"mention" -> "mentioned you"
|
||||
"reblog" -> "boosted your post"
|
||||
"favourite" -> "favorited your post"
|
||||
"follow" -> "followed you"
|
||||
"poll" -> "poll has ended"
|
||||
"status" -> "posted a new status"
|
||||
"follow_request" -> "requested to follow you"
|
||||
"update" -> "updated a post"
|
||||
else -> type
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time
|
||||
*/
|
||||
private fun formatTimestamp(timestamp: String): String {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(timestamp)
|
||||
val now = Date()
|
||||
val diff = now.time - (date?.time ?: 0)
|
||||
|
||||
when {
|
||||
diff < 60000 -> "now"
|
||||
diff < 3600000 -> "${diff / 60000}m"
|
||||
diff < 86400000 -> "${diff / 3600000}h"
|
||||
else -> "${diff / 86400000}d"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.manalejandro.myactivitypub.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.manalejandro.myactivitypub.data.models.Status
|
||||
import com.manalejandro.myactivitypub.data.repository.MastodonRepository
|
||||
import com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Screen for displaying a status detail with all its replies
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StatusDetailScreen(
|
||||
status: Status,
|
||||
repository: MastodonRepository,
|
||||
onBackClick: () -> Unit,
|
||||
onReplyClick: (Status) -> Unit = {},
|
||||
onStatusUpdated: (Status) -> Unit = {},
|
||||
isAuthenticated: Boolean = false
|
||||
) {
|
||||
// Create ViewModel for this status
|
||||
val viewModel: StatusDetailViewModel = viewModel(key = status.id) {
|
||||
StatusDetailViewModel(repository, status)
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val currentStatus by viewModel.currentStatus.collectAsState()
|
||||
val interactionState by viewModel.interactionState.collectAsState()
|
||||
|
||||
// Notify when status changes
|
||||
LaunchedEffect(currentStatus) {
|
||||
if (currentStatus.id == status.id && currentStatus != status) {
|
||||
onStatusUpdated(currentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle interaction state
|
||||
LaunchedEffect(interactionState) {
|
||||
when (interactionState) {
|
||||
is com.manalejandro.myactivitypub.ui.viewmodel.StatusInteractionState.Success -> {
|
||||
kotlinx.coroutines.delay(500)
|
||||
viewModel.resetInteractionState()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// Handle system back button
|
||||
BackHandler {
|
||||
onBackClick()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Post Detail") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
when (uiState) {
|
||||
is com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
is com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Success -> {
|
||||
val replies = (uiState as com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Success).replies
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
// Main status
|
||||
item {
|
||||
DetailedStatusCard(
|
||||
status = currentStatus,
|
||||
onReplyClick = onReplyClick,
|
||||
onBoostClick = { viewModel.toggleBoost() },
|
||||
onFavoriteClick = { viewModel.toggleFavorite() },
|
||||
isAuthenticated = isAuthenticated
|
||||
)
|
||||
|
||||
if (replies.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Replies (${replies.size})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Replies
|
||||
items(replies) { reply ->
|
||||
com.manalejandro.myactivitypub.ui.components.StatusCard(
|
||||
status = reply,
|
||||
onReplyClick = onReplyClick,
|
||||
onBoostClick = { /* Individual reply boost */ },
|
||||
onFavoriteClick = { /* Individual reply favorite */ },
|
||||
isAuthenticated = isAuthenticated
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is com.manalejandro.myactivitypub.ui.viewmodel.StatusDetailUiState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Error loading replies")
|
||||
Button(onClick = { viewModel.loadReplies() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed status card for the main post
|
||||
*/
|
||||
@Composable
|
||||
private fun DetailedStatusCard(
|
||||
status: Status,
|
||||
onReplyClick: (Status) -> Unit,
|
||||
onBoostClick: (Status) -> Unit,
|
||||
onFavoriteClick: (Status) -> Unit,
|
||||
isAuthenticated: Boolean
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Account info
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
AsyncImage(
|
||||
model = status.account.avatar,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = status.account.displayName.ifEmpty { status.account.username },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "@${status.account.acct}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Content
|
||||
Text(
|
||||
text = android.text.Html.fromHtml(
|
||||
status.content,
|
||||
android.text.Html.FROM_HTML_MODE_COMPACT
|
||||
).toString(),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
// Media attachments
|
||||
if (status.mediaAttachments.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
status.mediaAttachments.forEach { media ->
|
||||
if (media.type == "image") {
|
||||
AsyncImage(
|
||||
model = media.url,
|
||||
contentDescription = media.description,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = formatFullTimestamp(status.createdAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Stats
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
StatsItem("Replies", status.repliesCount)
|
||||
StatsItem("Boosts", status.reblogsCount)
|
||||
StatsItem("Favorites", status.favouritesCount)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Action buttons (same as StatusCard)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { if (isAuthenticated) onReplyClick(status) },
|
||||
enabled = isAuthenticated
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Reply,
|
||||
contentDescription = "Reply",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Reply")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { if (isAuthenticated) onBoostClick(status) },
|
||||
enabled = isAuthenticated
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Repeat,
|
||||
contentDescription = "Boost",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (status.reblogged) MaterialTheme.colorScheme.primary else LocalContentColor.current
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Boost")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { if (isAuthenticated) onFavoriteClick(status) },
|
||||
enabled = isAuthenticated
|
||||
) {
|
||||
Icon(
|
||||
if (status.favourited) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
contentDescription = "Favorite",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (status.favourited) MaterialTheme.colorScheme.error else LocalContentColor.current
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Favorite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsItem(label: String, count: Int) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFullTimestamp(timestamp: String): String {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(timestamp)
|
||||
val outputFormat = SimpleDateFormat("MMM dd, yyyy 'at' HH:mm", Locale.getDefault())
|
||||
outputFormat.format(date ?: Date())
|
||||
} catch (e: Exception) {
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/com/manalejandro/myactivitypub/ui/theme/Color.kt
Archivo normal
@@ -0,0 +1,27 @@
|
||||
package com.manalejandro.myactivitypub.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Mastodon-inspired color palette
|
||||
val MastodonPurple = Color(0xFF6364FF)
|
||||
val MastodonPurpleLight = Color(0xFF9B9CFF)
|
||||
val MastodonPurpleDark = Color(0xFF563ACC)
|
||||
|
||||
val LightBackground = Color(0xFFF8F8FF)
|
||||
val LightSurface = Color(0xFFFFFFFF)
|
||||
|
||||
val DarkBackground = Color(0xFF191B22)
|
||||
val DarkSurface = Color(0xFF282C37)
|
||||
|
||||
val AccentBlue = Color(0xFF2B90D9)
|
||||
val AccentGreen = Color(0xFF79BD9A)
|
||||
val AccentRed = Color(0xFFDF405A)
|
||||
|
||||
val Purple80 = MastodonPurpleLight
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = MastodonPurple
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
|
||||
58
app/src/main/java/com/manalejandro/myactivitypub/ui/theme/Theme.kt
Archivo normal
@@ -0,0 +1,58 @@
|
||||
package com.manalejandro.myactivitypub.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MyActivityPubTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/com/manalejandro/myactivitypub/ui/theme/Type.kt
Archivo normal
@@ -0,0 +1,34 @@
|
||||
package com.manalejandro.myactivitypub.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.manalejandro.myactivitypub.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.manalejandro.myactivitypub.data.models.UserSession
|
||||
import com.manalejandro.myactivitypub.data.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for managing authentication state
|
||||
*/
|
||||
class AuthViewModel(private val authRepository: AuthRepository) : ViewModel() {
|
||||
|
||||
private val _authState = MutableStateFlow<AuthState>(AuthState.Loading)
|
||||
val authState: StateFlow<AuthState> = _authState.asStateFlow()
|
||||
|
||||
val userSession: StateFlow<UserSession?> = authRepository.userSession
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
private fun checkAuthStatus() {
|
||||
viewModelScope.launch {
|
||||
authRepository.userSession.collect { session ->
|
||||
// Only update state if not in the middle of a login flow
|
||||
if (_authState.value !is AuthState.LoggingIn &&
|
||||
_authState.value !is AuthState.NeedsAuthorization) {
|
||||
_authState.value = if (session != null) {
|
||||
AuthState.Authenticated(session)
|
||||
} else {
|
||||
AuthState.NotAuthenticated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start login flow with instance
|
||||
*/
|
||||
fun startLogin(instance: String) {
|
||||
viewModelScope.launch {
|
||||
_authState.value = AuthState.LoggingIn
|
||||
|
||||
authRepository.registerApp(instance).fold(
|
||||
onSuccess = { app ->
|
||||
val authUrl = authRepository.getAuthorizationUrl(instance)
|
||||
_authState.value = AuthState.NeedsAuthorization(authUrl)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_authState.value = AuthState.Error(error.message ?: "Failed to register app")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login with authorization code
|
||||
*/
|
||||
fun completeLogin(code: String) {
|
||||
viewModelScope.launch {
|
||||
println("AuthViewModel: Starting completeLogin with code: ${code.take(10)}...")
|
||||
_authState.value = AuthState.LoggingIn
|
||||
|
||||
authRepository.obtainToken(code).fold(
|
||||
onSuccess = { token ->
|
||||
println("AuthViewModel: Token obtained successfully, loading user account...")
|
||||
// Token saved, now load user account info
|
||||
loadUserAccount()
|
||||
},
|
||||
onFailure = { error ->
|
||||
println("AuthViewModel: Failed to complete login: ${error.message}")
|
||||
_authState.value = AuthState.Error(error.message ?: "Failed to obtain token")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user account information
|
||||
*/
|
||||
private fun loadUserAccount() {
|
||||
viewModelScope.launch {
|
||||
// Wait a bit for the session to be available
|
||||
kotlinx.coroutines.delay(100)
|
||||
// The checkAuthStatus will automatically update when userSession changes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset error state
|
||||
*/
|
||||
fun resetError() {
|
||||
if (_authState.value is AuthState.Error) {
|
||||
_authState.value = AuthState.NotAuthenticated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication state
|
||||
*/
|
||||
sealed class AuthState {
|
||||
object Loading : AuthState()
|
||||
object NotAuthenticated : AuthState()
|
||||
object LoggingIn : AuthState()
|
||||
data class NeedsAuthorization(val authUrl: String) : AuthState()
|
||||
object LoginComplete : AuthState()
|
||||
data class Authenticated(val session: UserSession) : AuthState()
|
||||
data class Error(val message: String) : AuthState()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.manalejandro.myactivitypub.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.manalejandro.myactivitypub.data.models.Notification
|
||||
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 notifications
|
||||
*/
|
||||
class NotificationsViewModel(private val repository: MastodonRepository) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<NotificationsUiState>(NotificationsUiState.Loading)
|
||||
val uiState: StateFlow<NotificationsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load notifications
|
||||
*/
|
||||
fun loadNotifications() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = NotificationsUiState.Loading
|
||||
|
||||
repository.getNotifications().fold(
|
||||
onSuccess = { notifications ->
|
||||
_uiState.value = NotificationsUiState.Success(notifications)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.value = NotificationsUiState.Error(error.message ?: "Unknown error occurred")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI State for the Notifications screen
|
||||
*/
|
||||
sealed class NotificationsUiState {
|
||||
object Loading : NotificationsUiState()
|
||||
data class Success(val notifications: List<Notification>) : NotificationsUiState()
|
||||
data class Error(val message: String) : NotificationsUiState()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 status detail with replies
|
||||
*/
|
||||
class StatusDetailViewModel(
|
||||
private val repository: MastodonRepository,
|
||||
private val initialStatus: Status
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<StatusDetailUiState>(StatusDetailUiState.Loading)
|
||||
val uiState: StateFlow<StatusDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _currentStatus = MutableStateFlow(initialStatus)
|
||||
val currentStatus: StateFlow<Status> = _currentStatus.asStateFlow()
|
||||
|
||||
private val _interactionState = MutableStateFlow<StatusInteractionState>(StatusInteractionState.Idle)
|
||||
val interactionState: StateFlow<StatusInteractionState> = _interactionState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadReplies()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load replies for the status
|
||||
*/
|
||||
fun loadReplies() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = StatusDetailUiState.Loading
|
||||
|
||||
repository.getStatusContext(initialStatus.id).fold(
|
||||
onSuccess = { context ->
|
||||
_uiState.value = StatusDetailUiState.Success(context.descendants)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.value = StatusDetailUiState.Error(error.message ?: "Failed to load replies")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite on the main status
|
||||
*/
|
||||
fun toggleFavorite() {
|
||||
viewModelScope.launch {
|
||||
_interactionState.value = StatusInteractionState.Processing(_currentStatus.value.id)
|
||||
|
||||
val result = if (_currentStatus.value.favourited) {
|
||||
repository.unfavoriteStatus(_currentStatus.value.id)
|
||||
} else {
|
||||
repository.favoriteStatus(_currentStatus.value.id)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { updatedStatus ->
|
||||
_currentStatus.value = updatedStatus
|
||||
_interactionState.value = StatusInteractionState.Success
|
||||
},
|
||||
onFailure = { error ->
|
||||
_interactionState.value = StatusInteractionState.Error(error.message ?: "Failed to favorite")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle boost on the main status
|
||||
*/
|
||||
fun toggleBoost() {
|
||||
viewModelScope.launch {
|
||||
_interactionState.value = StatusInteractionState.Processing(_currentStatus.value.id)
|
||||
|
||||
val result = if (_currentStatus.value.reblogged) {
|
||||
repository.unboostStatus(_currentStatus.value.id)
|
||||
} else {
|
||||
repository.boostStatus(_currentStatus.value.id)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { updatedStatus ->
|
||||
_currentStatus.value = updatedStatus
|
||||
_interactionState.value = StatusInteractionState.Success
|
||||
},
|
||||
onFailure = { error ->
|
||||
_interactionState.value = StatusInteractionState.Error(error.message ?: "Failed to boost")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset interaction state
|
||||
*/
|
||||
fun resetInteractionState() {
|
||||
_interactionState.value = StatusInteractionState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI State for the Status Detail screen
|
||||
*/
|
||||
sealed class StatusDetailUiState {
|
||||
object Loading : StatusDetailUiState()
|
||||
data class Success(val replies: List<Status>) : StatusDetailUiState()
|
||||
data class Error(val message: String) : StatusDetailUiState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Interaction state for status actions in detail screen
|
||||
*/
|
||||
sealed class StatusInteractionState {
|
||||
object Idle : StatusInteractionState()
|
||||
data class Processing(val statusId: String) : StatusInteractionState()
|
||||
object Success : StatusInteractionState()
|
||||
data class Error(val message: String) : StatusInteractionState()
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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 timeline data and state
|
||||
*/
|
||||
class TimelineViewModel(private val repository: MastodonRepository) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<TimelineUiState>(TimelineUiState.Loading)
|
||||
val uiState: StateFlow<TimelineUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _timelineType = MutableStateFlow(TimelineType.PUBLIC)
|
||||
val timelineType: StateFlow<TimelineType> = _timelineType.asStateFlow()
|
||||
|
||||
private val _interactionState = MutableStateFlow<InteractionState>(InteractionState.Idle)
|
||||
val interactionState: StateFlow<InteractionState> = _interactionState.asStateFlow()
|
||||
|
||||
private val _isLoadingMore = MutableStateFlow(false)
|
||||
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
||||
|
||||
private var currentStatuses = mutableListOf<Status>()
|
||||
|
||||
init {
|
||||
loadTimeline()
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch timeline type
|
||||
*/
|
||||
fun switchTimeline(type: TimelineType) {
|
||||
_timelineType.value = type
|
||||
currentStatuses.clear()
|
||||
loadTimeline()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the timeline based on current type
|
||||
*/
|
||||
fun loadTimeline() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = TimelineUiState.Loading
|
||||
currentStatuses.clear()
|
||||
|
||||
val result = when (_timelineType.value) {
|
||||
TimelineType.PUBLIC -> repository.getPublicTimeline()
|
||||
TimelineType.HOME -> repository.getHomeTimeline()
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { statuses ->
|
||||
currentStatuses.addAll(statuses)
|
||||
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.value = TimelineUiState.Error(error.message ?: "Unknown error occurred")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more statuses (pagination)
|
||||
*/
|
||||
fun loadMore() {
|
||||
if (_isLoadingMore.value || currentStatuses.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_isLoadingMore.value = true
|
||||
|
||||
// Get the ID of the oldest status
|
||||
val maxId = currentStatuses.lastOrNull()?.id
|
||||
|
||||
val result = when (_timelineType.value) {
|
||||
TimelineType.PUBLIC -> repository.getPublicTimeline(maxId = maxId)
|
||||
TimelineType.HOME -> repository.getHomeTimeline(maxId = maxId)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { newStatuses ->
|
||||
if (newStatuses.isNotEmpty()) {
|
||||
currentStatuses.addAll(newStatuses)
|
||||
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
|
||||
}
|
||||
_isLoadingMore.value = false
|
||||
},
|
||||
onFailure = { error ->
|
||||
_isLoadingMore.value = false
|
||||
// Don't change main state on pagination error, just stop loading
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite on a status
|
||||
*/
|
||||
fun toggleFavorite(status: Status) {
|
||||
viewModelScope.launch {
|
||||
_interactionState.value = InteractionState.Processing(status.id)
|
||||
|
||||
val result = if (status.favourited) {
|
||||
repository.unfavoriteStatus(status.id)
|
||||
} else {
|
||||
repository.favoriteStatus(status.id)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { updatedStatus ->
|
||||
updateStatusInTimeline(updatedStatus)
|
||||
_interactionState.value = InteractionState.Success
|
||||
},
|
||||
onFailure = { error ->
|
||||
_interactionState.value = InteractionState.Error(error.message ?: "Failed to favorite")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle boost on a status
|
||||
*/
|
||||
fun toggleBoost(status: Status) {
|
||||
viewModelScope.launch {
|
||||
_interactionState.value = InteractionState.Processing(status.id)
|
||||
|
||||
val result = if (status.reblogged) {
|
||||
repository.unboostStatus(status.id)
|
||||
} else {
|
||||
repository.boostStatus(status.id)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { updatedStatus ->
|
||||
updateStatusInTimeline(updatedStatus)
|
||||
_interactionState.value = InteractionState.Success
|
||||
},
|
||||
onFailure = { error ->
|
||||
_interactionState.value = InteractionState.Error(error.message ?: "Failed to boost")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a reply to a status
|
||||
*/
|
||||
fun postReply(content: String, inReplyToId: String) {
|
||||
viewModelScope.launch {
|
||||
_interactionState.value = InteractionState.Processing(inReplyToId)
|
||||
|
||||
repository.postStatus(content, inReplyToId).fold(
|
||||
onSuccess = {
|
||||
_interactionState.value = InteractionState.ReplyPosted
|
||||
// Optionally reload timeline to show the new reply
|
||||
},
|
||||
onFailure = { error ->
|
||||
_interactionState.value = InteractionState.Error(error.message ?: "Failed to post reply")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a status in the current timeline
|
||||
*/
|
||||
private fun updateStatusInTimeline(updatedStatus: Status) {
|
||||
val index = currentStatuses.indexOfFirst { it.id == updatedStatus.id }
|
||||
if (index != -1) {
|
||||
currentStatuses[index] = updatedStatus
|
||||
_uiState.value = TimelineUiState.Success(currentStatuses.toList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status from external source (e.g., detail screen)
|
||||
*/
|
||||
fun updateStatus(updatedStatus: Status) {
|
||||
updateStatusInTimeline(updatedStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset interaction state
|
||||
*/
|
||||
fun resetInteractionState() {
|
||||
_interactionState.value = InteractionState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline type
|
||||
*/
|
||||
enum class TimelineType {
|
||||
PUBLIC, // Federated public timeline
|
||||
HOME // Authenticated user's home timeline
|
||||
}
|
||||
|
||||
/**
|
||||
* UI State for the Timeline screen
|
||||
*/
|
||||
sealed class TimelineUiState {
|
||||
object Loading : TimelineUiState()
|
||||
data class Success(val statuses: List<Status>) : TimelineUiState()
|
||||
data class Error(val message: String) : TimelineUiState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Interaction state for status actions
|
||||
*/
|
||||
sealed class InteractionState {
|
||||
object Idle : InteractionState()
|
||||
data class Processing(val statusId: String) : InteractionState()
|
||||
object Success : InteractionState()
|
||||
object ReplyPosted : InteractionState()
|
||||
data class Error(val message: String) : InteractionState()
|
||||
}
|
||||
|
||||
74
app/src/main/res/drawable/ic_launcher_background.xml
Archivo normal
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
37
app/src/main/res/drawable/ic_launcher_foreground.xml
Archivo normal
@@ -0,0 +1,37 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- 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"/>
|
||||
</vector>
|
||||
37
app/src/main/res/drawable/ic_launcher_foreground_new.xml
Archivo normal
@@ -0,0 +1,37 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- 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"/>
|
||||
</vector>
|
||||
43
app/src/main/res/drawable/ic_logo_myap.xml
Archivo normal
@@ -0,0 +1,43 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
|
||||
<!-- Background circle -->
|
||||
<path
|
||||
android:fillColor="#2B90D9"
|
||||
android:pathData="M256,256m-240,0a240,240 0,1 1,480 0a240,240 0,1 1,-480 0"/>
|
||||
|
||||
<!-- Inner glow circle -->
|
||||
<path
|
||||
android:fillColor="#3AA4E8"
|
||||
android:fillAlpha="0.3"
|
||||
android:pathData="M256,256m-220,0a220,220 0,1 1,440 0a220,220 0,1 1,-440 0"/>
|
||||
|
||||
<!-- Fediverse star symbol -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.9"
|
||||
android:pathData="M256,115 L263,132 L281,132 L266,143 L272,160 L256,149 L240,160 L246,143 L231,132 L249,132 Z"/>
|
||||
|
||||
<!-- My -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M140,220 L140,340 L155,340 L155,260 L185,320 L200,320 L230,260 L230,340 L245,340 L245,220 L220,220 L192.5,290 L165,220 Z"/>
|
||||
|
||||
<!-- y -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M265,260 L265,320 L245,360 L260,360 L280,330 L300,360 L315,360 L290,315 L290,260 Z"/>
|
||||
|
||||
<!-- A -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M325,340 L340,340 L340,300 L365,300 L365,340 L380,340 L380,220 L352.5,220 L325,340 M340,260 L365,260 L365,285 L340,285 Z"/>
|
||||
|
||||
<!-- P -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M395,220 L395,340 L410,340 L410,300 L435,300 Q445,300 445,290 L445,230 Q445,220 435,220 Z M410,235 L430,235 L430,285 L410,285 Z"/>
|
||||
</vector>
|
||||
28
app/src/main/res/drawable/logo_myap.xml
Archivo normal
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="256" cy="256" r="240" fill="#2B90D9"/>
|
||||
|
||||
<!-- Outer ring -->
|
||||
<circle cx="256" cy="256" r="240" fill="none" stroke="#1A6DA8" stroke-width="8"/>
|
||||
|
||||
<!-- Inner glow effect -->
|
||||
<circle cx="256" cy="256" r="220" fill="#3AA4E8" opacity="0.3"/>
|
||||
|
||||
<!-- MyAP Text -->
|
||||
<text x="256" y="280"
|
||||
font-family="Arial, Helvetica, sans-serif"
|
||||
font-size="140"
|
||||
font-weight="bold"
|
||||
fill="#FFFFFF"
|
||||
text-anchor="middle">
|
||||
MyAP
|
||||
</text>
|
||||
|
||||
<!-- Small ActivityPub icon/symbol (fediverse star) -->
|
||||
<g transform="translate(256, 140)">
|
||||
<path d="M 0,-25 L 7,-8 L 25,-8 L 10,3 L 16,20 L 0,9 L -16,20 L -10,3 L -25,-8 L -7,-8 Z"
|
||||
fill="#FFFFFF"
|
||||
opacity="0.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Después Anchura: | Altura: | Tamaño: 901 B |
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Archivo normal
@@ -0,0 +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"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Archivo normal
@@ -0,0 +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"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.9 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.4 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.0 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 7.7 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 5.3 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 10 KiB |
10
app/src/main/res/values/colors.xml
Archivo normal
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
app/src/main/res/values/strings.xml
Archivo normal
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">My ActivityPub</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Archivo normal
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.MyActivityPub" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Archivo normal
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Archivo normal
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
17
app/src/test/java/com/manalejandro/myactivitypub/ExampleUnitTest.kt
Archivo normal
@@ -0,0 +1,17 @@
|
||||
package com.manalejandro.myactivitypub
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||