mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
Merge branch 'worktree-agent-a5a483ec' into claude/ghost-microblog-android-utau1
This commit is contained in:
commit
b829ff5963
8 changed files with 841 additions and 1 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
package com.swoosh.microblog.data.api
|
package com.swoosh.microblog.data.api
|
||||||
|
|
||||||
|
import com.swoosh.microblog.data.model.PageWrapper
|
||||||
|
import com.swoosh.microblog.data.model.PagesResponse
|
||||||
import com.swoosh.microblog.data.model.PostWrapper
|
import com.swoosh.microblog.data.model.PostWrapper
|
||||||
import com.swoosh.microblog.data.model.PostsResponse
|
import com.swoosh.microblog.data.model.PostsResponse
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
|
|
@ -40,6 +42,28 @@ interface GhostApiService {
|
||||||
@GET("ghost/api/admin/users/me/")
|
@GET("ghost/api/admin/users/me/")
|
||||||
suspend fun getCurrentUser(): Response<UsersResponse>
|
suspend fun getCurrentUser(): Response<UsersResponse>
|
||||||
|
|
||||||
|
// --- Pages ---
|
||||||
|
|
||||||
|
@GET("ghost/api/admin/pages/")
|
||||||
|
suspend fun getPages(
|
||||||
|
@Query("limit") limit: String = "all",
|
||||||
|
@Query("formats") formats: String = "html,plaintext,mobiledoc"
|
||||||
|
): Response<PagesResponse>
|
||||||
|
|
||||||
|
@POST("ghost/api/admin/pages/")
|
||||||
|
@Headers("Content-Type: application/json")
|
||||||
|
suspend fun createPage(@Body body: PageWrapper): Response<PagesResponse>
|
||||||
|
|
||||||
|
@PUT("ghost/api/admin/pages/{id}/")
|
||||||
|
@Headers("Content-Type: application/json")
|
||||||
|
suspend fun updatePage(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Body body: PageWrapper
|
||||||
|
): Response<PagesResponse>
|
||||||
|
|
||||||
|
@DELETE("ghost/api/admin/pages/{id}/")
|
||||||
|
suspend fun deletePage(@Path("id") id: String): Response<Unit>
|
||||||
|
|
||||||
@Multipart
|
@Multipart
|
||||||
@POST("ghost/api/admin/images/upload/")
|
@POST("ghost/api/admin/images/upload/")
|
||||||
suspend fun uploadImage(
|
suspend fun uploadImage(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.swoosh.microblog.data.model
|
||||||
|
|
||||||
|
data class PagesResponse(
|
||||||
|
val pages: List<GhostPage>,
|
||||||
|
val meta: Meta?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PageWrapper(
|
||||||
|
val pages: List<GhostPage>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GhostPage(
|
||||||
|
val id: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val slug: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
val html: String? = null,
|
||||||
|
val plaintext: String? = null,
|
||||||
|
val mobiledoc: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val feature_image: String? = null,
|
||||||
|
val custom_excerpt: String? = null,
|
||||||
|
val created_at: String? = null,
|
||||||
|
val updated_at: String? = null,
|
||||||
|
val published_at: String? = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.swoosh.microblog.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.swoosh.microblog.data.AccountManager
|
||||||
|
import com.swoosh.microblog.data.api.ApiClient
|
||||||
|
import com.swoosh.microblog.data.api.GhostApiService
|
||||||
|
import com.swoosh.microblog.data.model.GhostPage
|
||||||
|
import com.swoosh.microblog.data.model.PageWrapper
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class PageRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private val accountManager = AccountManager(context)
|
||||||
|
|
||||||
|
private fun getApi(): GhostApiService {
|
||||||
|
val account = accountManager.getActiveAccount()
|
||||||
|
?: throw IllegalStateException("No active account configured")
|
||||||
|
return ApiClient.getService(account.blogUrl) { account.apiKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBlogUrl(): String? {
|
||||||
|
return accountManager.getActiveAccount()?.blogUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchPages(): Result<List<GhostPage>> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = getApi().getPages()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.success(response.body()!!.pages)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createPage(page: GhostPage): Result<GhostPage> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = getApi().createPage(PageWrapper(listOf(page)))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.success(response.body()!!.pages.first())
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Create failed ${response.code()}: ${response.errorBody()?.string()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePage(id: String, page: GhostPage): Result<GhostPage> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = getApi().updatePage(id, PageWrapper(listOf(page)))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.success(response.body()!!.pages.first())
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Update failed ${response.code()}: ${response.errorBody()?.string()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deletePage(id: String): Result<Unit> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = getApi().deletePage(id)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Delete failed ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ import com.swoosh.microblog.ui.composer.ComposerScreen
|
||||||
import com.swoosh.microblog.ui.composer.ComposerViewModel
|
import com.swoosh.microblog.ui.composer.ComposerViewModel
|
||||||
import com.swoosh.microblog.ui.detail.DetailScreen
|
import com.swoosh.microblog.ui.detail.DetailScreen
|
||||||
import com.swoosh.microblog.ui.feed.FeedScreen
|
import com.swoosh.microblog.ui.feed.FeedScreen
|
||||||
|
import com.swoosh.microblog.ui.pages.PagesScreen
|
||||||
import com.swoosh.microblog.ui.feed.FeedViewModel
|
import com.swoosh.microblog.ui.feed.FeedViewModel
|
||||||
import com.swoosh.microblog.ui.preview.PreviewScreen
|
import com.swoosh.microblog.ui.preview.PreviewScreen
|
||||||
import com.swoosh.microblog.ui.settings.SettingsScreen
|
import com.swoosh.microblog.ui.settings.SettingsScreen
|
||||||
|
|
@ -46,6 +47,7 @@ object Routes {
|
||||||
const val STATS = "stats"
|
const val STATS = "stats"
|
||||||
const val PREVIEW = "preview"
|
const val PREVIEW = "preview"
|
||||||
const val ADD_ACCOUNT = "add_account"
|
const val ADD_ACCOUNT = "add_account"
|
||||||
|
const val PAGES = "pages"
|
||||||
}
|
}
|
||||||
|
|
||||||
data class BottomNavItem(
|
data class BottomNavItem(
|
||||||
|
|
@ -255,6 +257,9 @@ fun SwooshNavGraph(
|
||||||
navController.navigate(Routes.SETUP) {
|
navController.navigate(Routes.SETUP) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onNavigateToPages = {
|
||||||
|
navController.navigate(Routes.PAGES)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +305,18 @@ fun SwooshNavGraph(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
Routes.PAGES,
|
||||||
|
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||||
|
exitTransition = { fadeOut(tween(150)) },
|
||||||
|
popEnterTransition = { fadeIn(tween(200)) },
|
||||||
|
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||||
|
) {
|
||||||
|
PagesScreen(
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
393
app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt
Normal file
393
app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
package com.swoosh.microblog.ui.pages
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.swoosh.microblog.data.MobiledocBuilder
|
||||||
|
import com.swoosh.microblog.data.model.GhostPage
|
||||||
|
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PagesScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: PagesViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
if (uiState.isEditing || uiState.isCreating) {
|
||||||
|
PageEditorScreen(
|
||||||
|
page = uiState.editingPage,
|
||||||
|
isCreating = uiState.isCreating,
|
||||||
|
isLoading = uiState.isLoading,
|
||||||
|
error = uiState.error,
|
||||||
|
blogUrl = viewModel.getBlogUrl(),
|
||||||
|
onSave = { title, content, slug, status ->
|
||||||
|
viewModel.savePage(title, content, slug, status)
|
||||||
|
},
|
||||||
|
onUpdate = { id, page ->
|
||||||
|
viewModel.updatePage(id, page)
|
||||||
|
},
|
||||||
|
onCancel = { viewModel.cancelEditing() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PagesListScreen(
|
||||||
|
pages = uiState.pages,
|
||||||
|
isLoading = uiState.isLoading,
|
||||||
|
error = uiState.error,
|
||||||
|
onBack = onBack,
|
||||||
|
onCreatePage = { viewModel.startCreating() },
|
||||||
|
onEditPage = { page -> viewModel.startEditing(page) },
|
||||||
|
onDeletePage = { id -> viewModel.deletePage(id) },
|
||||||
|
onRefresh = { viewModel.loadPages() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun PagesListScreen(
|
||||||
|
pages: List<GhostPage>,
|
||||||
|
isLoading: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onCreatePage: () -> Unit,
|
||||||
|
onEditPage: (GhostPage) -> Unit,
|
||||||
|
onDeletePage: (String) -> Unit,
|
||||||
|
onRefresh: () -> Unit
|
||||||
|
) {
|
||||||
|
var deletePageId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var deletePageTitle by remember { mutableStateOf("") }
|
||||||
|
var expandedMenuPageId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Pages") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onCreatePage) {
|
||||||
|
Icon(Icons.Default.Add, "New page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
if (isLoading && pages.isEmpty()) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
} else if (pages.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No pages yet.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(pages, key = { it.id ?: it.hashCode() }) { page ->
|
||||||
|
Box {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { onEditPage(page) },
|
||||||
|
onLongClick = { expandedMenuPageId = page.id }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = page.title ?: "Untitled",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
if (!page.slug.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "/${page.slug}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
StatusChip(status = page.status ?: "draft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expandedMenuPageId == page.id,
|
||||||
|
onDismissRequest = { expandedMenuPageId = null }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Edit") },
|
||||||
|
onClick = {
|
||||||
|
expandedMenuPageId = null
|
||||||
|
onEditPage(page)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Delete") },
|
||||||
|
onClick = {
|
||||||
|
expandedMenuPageId = null
|
||||||
|
deletePageId = page.id
|
||||||
|
deletePageTitle = page.title ?: "Untitled"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
Snackbar(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(16.dp),
|
||||||
|
action = {
|
||||||
|
TextButton(onClick = onRefresh) { Text("Retry") }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletePageId != null) {
|
||||||
|
ConfirmationDialog(
|
||||||
|
title = "Delete Page?",
|
||||||
|
message = "Delete \"$deletePageTitle\"? This cannot be undone.",
|
||||||
|
confirmLabel = "Delete",
|
||||||
|
onConfirm = {
|
||||||
|
deletePageId?.let { onDeletePage(it) }
|
||||||
|
deletePageId = null
|
||||||
|
},
|
||||||
|
onDismiss = { deletePageId = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusChip(status: String) {
|
||||||
|
val (color, label) = when (status) {
|
||||||
|
"published" -> Color(0xFF4CAF50) to "Published"
|
||||||
|
else -> Color(0xFF9C27B0) to "Draft"
|
||||||
|
}
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(label, style = MaterialTheme.typography.labelSmall) },
|
||||||
|
colors = AssistChipDefaults.assistChipColors(
|
||||||
|
labelColor = color
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, color.copy(alpha = 0.5f))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun PageEditorScreen(
|
||||||
|
page: GhostPage?,
|
||||||
|
isCreating: Boolean,
|
||||||
|
isLoading: Boolean,
|
||||||
|
error: String?,
|
||||||
|
blogUrl: String?,
|
||||||
|
onSave: (title: String, content: String, slug: String?, status: String) -> Unit,
|
||||||
|
onUpdate: (id: String, page: GhostPage) -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isNew = isCreating && page == null
|
||||||
|
|
||||||
|
var title by remember(page) { mutableStateOf(page?.title ?: "") }
|
||||||
|
var content by remember(page) { mutableStateOf(page?.plaintext ?: "") }
|
||||||
|
var slug by remember(page) { mutableStateOf(page?.slug ?: "") }
|
||||||
|
var selectedStatus by remember(page) {
|
||||||
|
mutableStateOf(if (page?.status == "published") "published" else "draft")
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(if (isNew) "New page" else "Edit page") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onCancel) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (isNew) {
|
||||||
|
onSave(title, content, slug.takeIf { it.isNotBlank() }, selectedStatus)
|
||||||
|
} else {
|
||||||
|
val mobiledoc = MobiledocBuilder.build(content, null, null, null)
|
||||||
|
val updatedPage = GhostPage(
|
||||||
|
title = title,
|
||||||
|
mobiledoc = mobiledoc,
|
||||||
|
slug = slug.takeIf { it.isNotBlank() },
|
||||||
|
status = selectedStatus,
|
||||||
|
updated_at = page?.updated_at
|
||||||
|
)
|
||||||
|
page?.id?.let { onUpdate(it, updatedPage) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = title.isNotBlank() && !isLoading
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, "Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
label = { Text("Title *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = content,
|
||||||
|
onValueChange = { content = it },
|
||||||
|
label = { Text("Content") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = slug,
|
||||||
|
onValueChange = { slug = it },
|
||||||
|
label = { Text("Slug (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status radio group
|
||||||
|
Text("Status", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Column(modifier = Modifier.selectableGroup()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = selectedStatus == "draft",
|
||||||
|
onClick = { selectedStatus = "draft" },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedStatus == "draft",
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Draft")
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = selectedStatus == "published",
|
||||||
|
onClick = { selectedStatus = "published" },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedStatus == "published",
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Publish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in browser for published pages
|
||||||
|
if (!isNew && page?.status == "published" && !page.slug.isNullOrBlank() && blogUrl != null) {
|
||||||
|
val pageUrl = "${blogUrl.removeSuffix("/")}/${page.slug}/"
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(pageUrl))
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Open in browser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert to draft for published pages
|
||||||
|
if (!isNew && page?.status == "published") {
|
||||||
|
TextButton(
|
||||||
|
onClick = { selectedStatus = "draft" },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Revert to draft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package com.swoosh.microblog.ui.pages
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.swoosh.microblog.data.MobiledocBuilder
|
||||||
|
import com.swoosh.microblog.data.model.GhostPage
|
||||||
|
import com.swoosh.microblog.data.repository.PageRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class PagesViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
private val repository = PageRepository(application)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(PagesUiState())
|
||||||
|
val uiState: StateFlow<PagesUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadPages()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repository.fetchPages().fold(
|
||||||
|
onSuccess = { pages ->
|
||||||
|
_uiState.update { it.copy(pages = pages, isLoading = false) }
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePage(title: String, content: String, slug: String?, status: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
val mobiledoc = MobiledocBuilder.build(content, null, null, null)
|
||||||
|
val page = GhostPage(
|
||||||
|
title = title,
|
||||||
|
mobiledoc = mobiledoc,
|
||||||
|
slug = slug?.takeIf { it.isNotBlank() },
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
repository.createPage(page).fold(
|
||||||
|
onSuccess = {
|
||||||
|
_uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) }
|
||||||
|
loadPages()
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePage(id: String, page: GhostPage) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repository.updatePage(id, page).fold(
|
||||||
|
onSuccess = {
|
||||||
|
_uiState.update { it.copy(isEditing = false, isCreating = false, editingPage = null) }
|
||||||
|
loadPages()
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deletePage(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repository.deletePage(id).fold(
|
||||||
|
onSuccess = { loadPages() },
|
||||||
|
onFailure = { e ->
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startEditing(page: GhostPage) {
|
||||||
|
_uiState.update { it.copy(editingPage = page, isEditing = true, isCreating = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startCreating() {
|
||||||
|
_uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelEditing() {
|
||||||
|
_uiState.update { it.copy(editingPage = null, isEditing = false, isCreating = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBlogUrl(): String? = repository.getBlogUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PagesUiState(
|
||||||
|
val pages: List<GhostPage> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val editingPage: GhostPage? = null,
|
||||||
|
val isEditing: Boolean = false,
|
||||||
|
val isCreating: Boolean = false
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ package com.swoosh.microblog.ui.settings
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
|
@ -30,7 +31,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
themeViewModel: ThemeViewModel? = null
|
themeViewModel: ThemeViewModel? = null,
|
||||||
|
onNavigateToPages: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val accountManager = remember { AccountManager(context) }
|
val accountManager = remember { AccountManager(context) }
|
||||||
|
|
@ -128,6 +130,30 @@ fun SettingsScreen(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Static Pages
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onNavigateToPages)
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Static Pages",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "\u203A",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Disconnect current account
|
// Disconnect current account
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { showDisconnectDialog = true },
|
onClick = { showDisconnectDialog = true },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
package com.swoosh.microblog.data.model
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PageModelsTest {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
// --- GhostPage defaults ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPage all fields default to null`() {
|
||||||
|
val page = GhostPage()
|
||||||
|
assertNull(page.id)
|
||||||
|
assertNull(page.title)
|
||||||
|
assertNull(page.slug)
|
||||||
|
assertNull(page.url)
|
||||||
|
assertNull(page.html)
|
||||||
|
assertNull(page.plaintext)
|
||||||
|
assertNull(page.mobiledoc)
|
||||||
|
assertNull(page.status)
|
||||||
|
assertNull(page.feature_image)
|
||||||
|
assertNull(page.custom_excerpt)
|
||||||
|
assertNull(page.created_at)
|
||||||
|
assertNull(page.updated_at)
|
||||||
|
assertNull(page.published_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPage stores all fields correctly`() {
|
||||||
|
val page = GhostPage(
|
||||||
|
id = "page-1",
|
||||||
|
title = "About",
|
||||||
|
slug = "about",
|
||||||
|
url = "https://blog.example.com/about/",
|
||||||
|
html = "<p>About us</p>",
|
||||||
|
plaintext = "About us",
|
||||||
|
mobiledoc = """{"version":"0.3.1"}""",
|
||||||
|
status = "published",
|
||||||
|
feature_image = "https://blog.example.com/img.jpg",
|
||||||
|
custom_excerpt = "Learn more about us",
|
||||||
|
created_at = "2024-01-01T00:00:00.000Z",
|
||||||
|
updated_at = "2024-06-15T12:00:00.000Z",
|
||||||
|
published_at = "2024-01-02T00:00:00.000Z"
|
||||||
|
)
|
||||||
|
assertEquals("page-1", page.id)
|
||||||
|
assertEquals("About", page.title)
|
||||||
|
assertEquals("about", page.slug)
|
||||||
|
assertEquals("https://blog.example.com/about/", page.url)
|
||||||
|
assertEquals("<p>About us</p>", page.html)
|
||||||
|
assertEquals("About us", page.plaintext)
|
||||||
|
assertEquals("published", page.status)
|
||||||
|
assertEquals("https://blog.example.com/img.jpg", page.feature_image)
|
||||||
|
assertEquals("Learn more about us", page.custom_excerpt)
|
||||||
|
assertEquals("2024-01-01T00:00:00.000Z", page.created_at)
|
||||||
|
assertEquals("2024-06-15T12:00:00.000Z", page.updated_at)
|
||||||
|
assertEquals("2024-01-02T00:00:00.000Z", page.published_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GSON serialization ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPage serializes to JSON correctly`() {
|
||||||
|
val page = GhostPage(
|
||||||
|
id = "abc123",
|
||||||
|
title = "Contact",
|
||||||
|
slug = "contact",
|
||||||
|
status = "published"
|
||||||
|
)
|
||||||
|
val json = gson.toJson(page)
|
||||||
|
assertTrue(json.contains("\"id\":\"abc123\""))
|
||||||
|
assertTrue(json.contains("\"title\":\"Contact\""))
|
||||||
|
assertTrue(json.contains("\"slug\":\"contact\""))
|
||||||
|
assertTrue(json.contains("\"status\":\"published\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPage deserializes from JSON correctly`() {
|
||||||
|
val json = """{"id":"xyz","title":"FAQ","slug":"faq","status":"draft"}"""
|
||||||
|
val page = gson.fromJson(json, GhostPage::class.java)
|
||||||
|
assertEquals("xyz", page.id)
|
||||||
|
assertEquals("FAQ", page.title)
|
||||||
|
assertEquals("faq", page.slug)
|
||||||
|
assertEquals("draft", page.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPage deserializes with missing optional fields`() {
|
||||||
|
val json = """{"id":"test"}"""
|
||||||
|
val page = gson.fromJson(json, GhostPage::class.java)
|
||||||
|
assertEquals("test", page.id)
|
||||||
|
assertNull(page.title)
|
||||||
|
assertNull(page.slug)
|
||||||
|
assertNull(page.html)
|
||||||
|
assertNull(page.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PagesResponse ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PagesResponse deserializes with pages and pagination`() {
|
||||||
|
val json = """{
|
||||||
|
"pages": [{"id": "1", "title": "About"}, {"id": "2", "title": "Contact"}],
|
||||||
|
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
|
||||||
|
}"""
|
||||||
|
val response = gson.fromJson(json, PagesResponse::class.java)
|
||||||
|
assertEquals(2, response.pages.size)
|
||||||
|
assertEquals("1", response.pages[0].id)
|
||||||
|
assertEquals("About", response.pages[0].title)
|
||||||
|
assertEquals("2", response.pages[1].id)
|
||||||
|
assertEquals("Contact", response.pages[1].title)
|
||||||
|
assertEquals(1, response.meta?.pagination?.page)
|
||||||
|
assertEquals(2, response.meta?.pagination?.total)
|
||||||
|
assertNull(response.meta?.pagination?.next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PagesResponse deserializes with empty pages list`() {
|
||||||
|
val json = """{"pages": [], "meta": null}"""
|
||||||
|
val response = gson.fromJson(json, PagesResponse::class.java)
|
||||||
|
assertTrue(response.pages.isEmpty())
|
||||||
|
assertNull(response.meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PageWrapper ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PageWrapper wraps pages for API request`() {
|
||||||
|
val wrapper = PageWrapper(listOf(GhostPage(title = "New Page", status = "draft")))
|
||||||
|
val json = gson.toJson(wrapper)
|
||||||
|
assertTrue(json.contains("\"pages\""))
|
||||||
|
assertTrue(json.contains("\"New Page\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PageWrapper serializes single page correctly`() {
|
||||||
|
val page = GhostPage(
|
||||||
|
title = "About Us",
|
||||||
|
slug = "about-us",
|
||||||
|
status = "published",
|
||||||
|
mobiledoc = """{"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Welcome"]]]]}"""
|
||||||
|
)
|
||||||
|
val wrapper = PageWrapper(listOf(page))
|
||||||
|
val json = gson.toJson(wrapper)
|
||||||
|
assertTrue(json.contains("\"title\":\"About Us\""))
|
||||||
|
assertTrue(json.contains("\"slug\":\"about-us\""))
|
||||||
|
assertTrue(json.contains("\"status\":\"published\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GhostPage updated_at is preserved for PUT requests`() {
|
||||||
|
val page = GhostPage(
|
||||||
|
id = "page-1",
|
||||||
|
title = "Updated Title",
|
||||||
|
updated_at = "2024-06-15T12:00:00.000Z"
|
||||||
|
)
|
||||||
|
val json = gson.toJson(page)
|
||||||
|
assertTrue(json.contains("\"updated_at\":\"2024-06-15T12:00:00.000Z\""))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue