From d83309f8bc3671fc14f2d3c49a90d218874f5dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:27:33 +0100 Subject: [PATCH 1/3] feat: add Pages API model, endpoints, and model tests Introduce GhostPage, PagesResponse, PageWrapper data classes for Ghost CMS static pages. Add CRUD endpoints (getPages, createPage, updatePage, deletePage) to GhostApiService. Include comprehensive unit tests for serialization and default values. --- .../microblog/data/api/GhostApiService.kt | 24 +++ .../swoosh/microblog/data/model/PageModels.kt | 26 +++ .../microblog/data/model/PageModelsTest.kt | 162 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/PageModelsTest.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index cd41155..f3d240c 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -1,5 +1,7 @@ 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.PostsResponse import okhttp3.MultipartBody @@ -40,6 +42,28 @@ interface GhostApiService { @GET("ghost/api/admin/users/me/") suspend fun getCurrentUser(): Response + // --- Pages --- + + @GET("ghost/api/admin/pages/") + suspend fun getPages( + @Query("limit") limit: String = "all", + @Query("formats") formats: String = "html,plaintext,mobiledoc" + ): Response + + @POST("ghost/api/admin/pages/") + @Headers("Content-Type: application/json") + suspend fun createPage(@Body body: PageWrapper): Response + + @PUT("ghost/api/admin/pages/{id}/") + @Headers("Content-Type: application/json") + suspend fun updatePage( + @Path("id") id: String, + @Body body: PageWrapper + ): Response + + @DELETE("ghost/api/admin/pages/{id}/") + suspend fun deletePage(@Path("id") id: String): Response + @Multipart @POST("ghost/api/admin/images/upload/") suspend fun uploadImage( diff --git a/app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt new file mode 100644 index 0000000..4524b67 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt @@ -0,0 +1,26 @@ +package com.swoosh.microblog.data.model + +data class PagesResponse( + val pages: List, + val meta: Meta? +) + +data class PageWrapper( + val pages: List +) + +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 +) diff --git a/app/src/test/java/com/swoosh/microblog/data/model/PageModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/PageModelsTest.kt new file mode 100644 index 0000000..e3a704c --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/PageModelsTest.kt @@ -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 = "

About us

", + 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("

About us

", 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\"")) + } +} From a558a2f289c99f216ad6ccdf3967dbfcb6ef46c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:28:01 +0100 Subject: [PATCH 2/3] feat: add PageRepository for Ghost Pages CRUD operations Follows PostRepository pattern with AccountManager-based auth, Dispatchers.IO coroutine context, and Result return types. Exposes fetchPages, createPage, updatePage, deletePage methods plus getBlogUrl for constructing page URLs in the UI. --- .../data/repository/PageRepository.kt | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt new file mode 100644 index 0000000..5fa6d50 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt @@ -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> = + 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 = + 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 = + 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 = + 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) + } + } +} From 83b779155eed384a2a09f12d09d7c8bcec4e7ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:31:22 +0100 Subject: [PATCH 3/3] feat: add Pages list and editor screen with Settings navigation Add PagesViewModel with CRUD operations and edit/create state management. Add PagesScreen with dual-mode UI (list with long-press context menu and editor with title/content/slug/status fields). Wire navigation from Settings via "Static Pages" row. Pages use slide-in-horizontal transition consistent with other detail screens. --- .../microblog/ui/navigation/NavGraph.kt | 17 + .../swoosh/microblog/ui/pages/PagesScreen.kt | 393 ++++++++++++++++++ .../microblog/ui/pages/PagesViewModel.kt | 111 +++++ .../microblog/ui/settings/SettingsScreen.kt | 28 +- 4 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt create mode 100644 app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index bb4c93f..8325ee4 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -29,6 +29,7 @@ import com.swoosh.microblog.ui.composer.ComposerScreen import com.swoosh.microblog.ui.composer.ComposerViewModel import com.swoosh.microblog.ui.detail.DetailScreen 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.preview.PreviewScreen import com.swoosh.microblog.ui.settings.SettingsScreen @@ -46,6 +47,7 @@ object Routes { const val STATS = "stats" const val PREVIEW = "preview" const val ADD_ACCOUNT = "add_account" + const val PAGES = "pages" } data class BottomNavItem( @@ -255,6 +257,9 @@ fun SwooshNavGraph( navController.navigate(Routes.SETUP) { 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() } + ) + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt new file mode 100644 index 0000000..6304b1d --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt @@ -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, + isLoading: Boolean, + error: String?, + onBack: () -> Unit, + onCreatePage: () -> Unit, + onEditPage: (GhostPage) -> Unit, + onDeletePage: (String) -> Unit, + onRefresh: () -> Unit +) { + var deletePageId by remember { mutableStateOf(null) } + var deletePageTitle by remember { mutableStateOf("") } + var expandedMenuPageId by remember { mutableStateOf(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)) + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt new file mode 100644 index 0000000..e3de283 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt @@ -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 = _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 = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val editingPage: GhostPage? = null, + val isEditing: Boolean = false, + val isCreating: Boolean = false +) diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index 28e7248..e80e9f8 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -2,6 +2,7 @@ package com.swoosh.microblog.ui.settings import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -30,7 +31,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel fun SettingsScreen( onBack: () -> Unit, onLogout: () -> Unit, - themeViewModel: ThemeViewModel? = null + themeViewModel: ThemeViewModel? = null, + onNavigateToPages: () -> Unit = {} ) { val context = LocalContext.current val accountManager = remember { AccountManager(context) } @@ -128,6 +130,30 @@ fun SettingsScreen( HorizontalDivider() 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 OutlinedButton( onClick = { showDisconnectDialog = true },