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.
This commit is contained in:
Paweł Orzech 2026-03-20 00:31:22 +01:00
parent a558a2f289
commit 83b779155e
4 changed files with 548 additions and 1 deletions

View file

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

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

View file

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

View file

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