From 9f068dcae4aedd5392896c5fe91ccf60f5029842 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:46:35 -0400 Subject: [PATCH 01/12] feat(import): add OpenPlural import source OpenPlural v0.1 is a cross-app interchange format Sheaf now reads. The importer mirrors the existing Sheaf importer (same preview shape + async job/poll flow), differing only in the preview endpoint and that OpenPlural uses a single import source for both the bare .json and the .openplural.zip bundle (the runner sniffs the zip magic and unpacks images). Reuses SheafPreviewSummary/SheafImportResult (the backend returns the Sheaf shape plus lineage_length, which the preview surfaces). Wired into the Data settings import menu and nav. --- .../lupine/sheaf/data/api/SheafApiService.kt | 12 + .../systems/lupine/sheaf/data/model/Models.kt | 7 + .../java/systems/lupine/sheaf/ui/SheafApp.kt | 7 + .../OpenPluralImportScreen.kt | 262 ++++++++++++++++++ .../OpenPluralImportViewModel.kt | 199 +++++++++++++ .../ui/settings/SettingsCategoryScreens.kt | 8 + 6 files changed, 495 insertions(+) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportScreen.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportViewModel.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index 3b001f4..eae3c18 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -474,6 +474,18 @@ interface SheafApiService { @Part file: MultipartBody.Part, ): PluralSpacePreviewSummary + /** + * Preview an OpenPlural v0.1 import. Accepts a bare `.json` export or an + * `.openplural.zip` bundle (the endpoint sniffs the zip magic). Reuses the + * Sheaf preview shape plus a `lineage_length`; submit via [createFileImport] + * with source [ImportJobSource.OPENPLURAL_FILE]. + */ + @Multipart + @POST("/v1/import/openplural/preview") + suspend fun previewOpenPluralImport( + @Part file: MultipartBody.Part, + ): SheafPreviewSummary + /** * Preview a Prism (.prism) export. The PRISM1 envelope is decrypted * server-side with [passphrase]; nothing is persisted. Submit goes diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 32d178a..a5b2539 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -734,6 +734,9 @@ data class SheafPreviewSummary( // plain-JSON shape so older backends that don't return these still parse. val archive: Boolean = false, @Json(name = "image_count") val imageCount: Int = 0, + // OpenPlural previews also report how many prior exports the file has + // passed through (its lineage). Absent (0) for native Sheaf previews. + @Json(name = "lineage_length") val lineageLength: Int = 0, ) @JsonClass(generateAdapter = true) @@ -956,6 +959,10 @@ object ImportJobSource { const val PLURALSPACE_FILE = "pluralspace_file" // Passphrase-encrypted .prism export; the passphrase rides as `credential`. const val PRISM_FILE = "prism_file" + // OpenPlural v0.1 interchange file. One source for both the bare .json + // and the .openplural.zip bundle; the runner sniffs the zip magic and + // unpacks images when present (no separate archive source like Sheaf). + const val OPENPLURAL_FILE = "openplural_file" } /** diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index 77d5bc4..b7147cf 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -81,6 +81,7 @@ object Routes { const val TB_IMPORT = "settings/import/tupperbox" const val PS_IMPORT = "settings/import/pluralspace" const val PRISM_IMPORT = "settings/import/prism" + const val OPENPLURAL_IMPORT = "settings/import/openplural" const val IMPORT_HISTORY = "settings/import/history" const val IMPORT_DETAIL = "settings/import/history/{jobId}" const val CUSTOM_FIELDS = "settings/fields" @@ -491,6 +492,7 @@ fun SheafApp( onNavigateToTupperboxImport = { navController.navigate(Routes.TB_IMPORT) }, onNavigateToPluralSpaceImport = { navController.navigate(Routes.PS_IMPORT) }, onNavigateToPrismImport = { navController.navigate(Routes.PRISM_IMPORT) }, + onNavigateToOpenPluralImport = { navController.navigate(Routes.OPENPLURAL_IMPORT) }, onNavigateToImportHistory = { navController.navigate(Routes.IMPORT_HISTORY) }, ) } @@ -545,6 +547,11 @@ fun SheafApp( onNavigateUp = { navController.navigateUp() }, ) } + composable(Routes.OPENPLURAL_IMPORT) { + systems.lupine.sheaf.ui.openpluralimport.OpenPluralImportScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } composable(Routes.IMPORT_HISTORY) { systems.lupine.sheaf.ui.imports.ImportHistoryScreen( onNavigateUp = { navController.navigateUp() }, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportScreen.kt new file mode 100644 index 0000000..6686739 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportScreen.kt @@ -0,0 +1,262 @@ +package systems.lupine.sheaf.ui.openpluralimport + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import systems.lupine.sheaf.data.model.SheafImportResult +import systems.lupine.sheaf.data.model.SheafPreviewSummary +import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.SectionHeader +import systems.lupine.sheaf.ui.components.SheafTopAppBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OpenPluralImportScreen( + onNavigateUp: () -> Unit, + viewModel: OpenPluralImportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> uri?.let { viewModel.pickFile(it) } } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Import from OpenPlural") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.error != null) ErrorBanner(state.error!!) + + when { + state.result != null -> ResultSection( + result = state.result!!, + onImportAnother = { viewModel.reset() }, + ) + state.isImporting -> CenterSpinner("Importing…") + state.preview != null -> PreviewSection( + fileName = state.fileName!!, + preview = state.preview!!, + options = state.options, + onUpdateOptions = { viewModel.updateOptions(it) }, + onImport = { viewModel.runImport() }, + onChangeFile = { filePicker.launch(arrayOf("*/*")) }, + ) + state.isPreviewing -> CenterSpinner("Reading file…") + else -> FilePickSection(onPick = { filePicker.launch(arrayOf("*/*")) }) + } + } + } +} + +@Composable +private fun CenterSpinner(label: String) { + Box( + Modifier.fillMaxWidth().padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + CircularProgressIndicator() + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun FilePickSection(onPick: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Outlined.FileOpen, + contentDescription = null, + modifier = Modifier.size(52.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Text( + "Choose an OpenPlural export from another compatible app: the plain .json, or an .openplural.zip backup that also includes images.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onPick) { Text("Choose file") } + Text( + "OpenPlural is an interchange format shared across plural apps. A .json brings your data across; an .openplural.zip brings your images too.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } +} + +@Composable +private fun PreviewSection( + fileName: String, + preview: SheafPreviewSummary, + options: OpenPluralImportOptions, + onUpdateOptions: (OpenPluralImportOptions.() -> OpenPluralImportOptions) -> Unit, + onImport: () -> Unit, + onChangeFile: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(fileName, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1) + TextButton(onClick = onChangeFile) { Text("Change") } + } + + // Lineage: how many prior exports this file has passed through. Helps a + // user understand round-trips across apps. + if (preview.lineageLength > 0) { + Text( + "Passed through ${preview.lineageLength} prior export${if (preview.lineageLength == 1) "" else "s"}.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + HorizontalDivider() + SectionHeader("What to import") + + ToggleRow( + label = "System profile", + checked = options.systemProfile, + onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, + ) + + if (preview.memberCount > 0) { + ToggleRow(label = "Members (${preview.memberCount})", checked = true, onCheckedChange = {}, enabled = false) + } + + if (preview.frontCount > 0) { + ToggleRow( + label = "Front history (${preview.frontCount} entries)", + checked = options.fronts, + onCheckedChange = { onUpdateOptions { copy(fronts = it) } }, + ) + } + + if (preview.groupCount > 0) { + ToggleRow( + label = "Groups (${preview.groupCount})", + checked = options.groups, + onCheckedChange = { onUpdateOptions { copy(groups = it) } }, + ) + } + + if (preview.tagCount > 0) { + ToggleRow( + label = "Tags (${preview.tagCount})", + checked = options.tags, + onCheckedChange = { onUpdateOptions { copy(tags = it) } }, + ) + } + + if (preview.customFieldCount > 0) { + ToggleRow( + label = "Custom fields (${preview.customFieldCount})", + checked = options.customFields, + onCheckedChange = { onUpdateOptions { copy(customFields = it) } }, + ) + } + + // Bundle images ride with the records they belong to (avatars, banners), + // so there's no separate toggle; surface the count so the user knows. + if (preview.archive && preview.imageCount > 0) { + ToggleRow(label = "Images (${preview.imageCount})", checked = true, onCheckedChange = {}, enabled = false) + } + + Spacer(Modifier.height(4.dp)) + + Button( + onClick = onImport, + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { Text("Import") } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun ResultSection(result: SheafImportResult, onImportAnother: () -> Unit) { + SectionHeader("Import complete") + + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + ResultRow("Members imported", result.membersImported) + ResultRow("Front history entries", result.frontsImported) + ResultRow("Groups imported", result.groupsImported) + ResultRow("Tags imported", result.tagsImported) + ResultRow("Custom fields imported", result.customFieldsImported) + if (result.imagesImported > 0) { + ResultRow("Images imported", result.imagesImported) + } + } + } + + if (result.warnings.isNotEmpty()) { + SectionHeader("Warnings") + result.warnings.forEach { warning -> + Text( + "• $warning", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(4.dp)) + OutlinedButton(onClick = onImportAnother, modifier = Modifier.fillMaxWidth()) { + Text("Import another file") + } +} + +@Composable +private fun ResultRow(label: String, count: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(count.toString(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportViewModel.kt new file mode 100644 index 0000000..0a8ff76 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportViewModel.kt @@ -0,0 +1,199 @@ +package systems.lupine.sheaf.ui.openpluralimport + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.api.streamingFilePart +import systems.lupine.sheaf.data.model.ImportJobRead +import systems.lupine.sheaf.data.model.ImportJobSource +import systems.lupine.sheaf.data.model.ImportJobStatus +import systems.lupine.sheaf.data.model.SheafImportResult +import systems.lupine.sheaf.data.model.SheafPreviewSummary +import systems.lupine.sheaf.util.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.UUID +import javax.inject.Inject + +/** + * OpenPlural import. Mirrors the Sheaf importer (same preview shape, same + * options, same async job + poll flow); the only differences are the + * preview endpoint and that OpenPlural uses a single import source for both + * the bare `.json` and the `.openplural.zip` bundle. Reuses + * [SheafPreviewSummary] / [SheafImportResult] since the backend returns the + * Sheaf shape (plus `lineage_length`). + */ +data class OpenPluralImportOptions( + val systemProfile: Boolean = true, + val fronts: Boolean = true, + val groups: Boolean = true, + val tags: Boolean = true, + val customFields: Boolean = true, +) + +data class OpenPluralImportUiState( + val fileName: String? = null, + val isPreviewing: Boolean = false, + val preview: SheafPreviewSummary? = null, + val options: OpenPluralImportOptions = OpenPluralImportOptions(), + val isImporting: Boolean = false, + val result: SheafImportResult? = null, + val error: String? = null, +) + +@HiltViewModel +class OpenPluralImportViewModel @Inject constructor( + private val api: SheafApiService, + @ApplicationContext private val context: Context, +) : ViewModel() { + + private val _state = MutableStateFlow(OpenPluralImportUiState()) + val state: StateFlow = _state.asStateFlow() + + private var fileUri: Uri? = null + private var cachedFileName: String? = null + + fun pickFile(uri: Uri) { + viewModelScope.launch { + _state.update { it.copy(isPreviewing = true, error = null, preview = null, result = null) } + fileUri = uri + val name = resolveFileName(uri) ?: "openplural_export.json" + cachedFileName = name + _state.update { it.copy(fileName = name) } + preview(uri, name) + } + } + + private suspend fun preview(uri: Uri, name: String) { + runCatching { api.previewOpenPluralImport(filePart(uri, name)) } + .onSuccess { summary -> + _state.update { + it.copy( + isPreviewing = false, + preview = summary, + options = OpenPluralImportOptions( + systemProfile = true, + fronts = summary.frontCount > 0, + groups = summary.groupCount > 0, + tags = summary.tagCount > 0, + customFields = summary.customFieldCount > 0, + ), + ) + } + } + .onFailure { e -> _state.update { it.copy(isPreviewing = false, error = e.toUserMessage("Preview failed - check the file and try again")) } } + } + + fun updateOptions(update: OpenPluralImportOptions.() -> OpenPluralImportOptions) { + _state.update { it.copy(options = it.options.update()) } + } + + fun runImport() { + val uri = fileUri ?: return + val name = cachedFileName ?: "openplural_export.json" + val opts = _state.value.options + viewModelScope.launch { + _state.update { it.copy(isImporting = true, error = null) } + runCatching { + val job = api.createFileImport( + file = filePart(uri, name), + source = ImportJobSource.OPENPLURAL_FILE.toFormPart(), + idempotencyKey = UUID.randomUUID().toString().toFormPart(), + options = buildOptionsJson(opts).toJsonPart(), + ) + pollUntilTerminal(job) + } + .onSuccess { final -> handleTerminal(final) } + .onFailure { e -> _state.update { it.copy(isImporting = false, error = e.toUserMessage("Import failed - please try again")) } } + } + } + + private suspend fun pollUntilTerminal(initial: ImportJobRead): ImportJobRead { + var current = initial + while (current.status !in ImportJobStatus.terminal) { + delay(POLL_INTERVAL_MS) + current = api.getImportJob(current.id) + } + return current + } + + private fun handleTerminal(job: ImportJobRead) { + when (job.status) { + ImportJobStatus.COMPLETE -> { + val counts = job.counts + val warnings = job.events + .filter { it.level == "warning" } + .map { e -> e.recordRef?.let { "$it: ${e.message}" } ?: e.message } + val result = SheafImportResult( + membersImported = counts["members_imported"] ?: 0, + frontsImported = counts["fronts_imported"] ?: 0, + groupsImported = counts["groups_imported"] ?: 0, + tagsImported = counts["tags_imported"] ?: 0, + customFieldsImported = counts["custom_fields_imported"] ?: 0, + imagesImported = counts["images_imported"] ?: 0, + warnings = warnings, + ) + _state.update { it.copy(isImporting = false, result = result) } + } + else -> _state.update { + it.copy( + isImporting = false, + error = job.lastError ?: "Import didn't complete (status: ${job.status})", + ) + } + } + } + + fun reset() { + fileUri = null + cachedFileName = null + _state.value = OpenPluralImportUiState() + } + + private fun filePart(uri: Uri, name: String) = + streamingFilePart(context.contentResolver, uri, name) + + private fun String.toFormPart(): RequestBody = + toRequestBody("text/plain".toMediaType()) + + private fun String.toJsonPart(): RequestBody = + toRequestBody("application/json".toMediaType()) + + private fun resolveFileName(uri: Uri): String? = runCatching { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(col) + } + }.getOrNull() + + companion object { + private const val POLL_INTERVAL_MS: Long = 1500 + } +} + +/** + * Hand-built options JSON; the backend uses extra="forbid" so a typo'd + * field 422s. Only the toggles the screen exposes are surfaced; the rest + * (journals, messages, polls, reminders, notifications, images) fall through + * to their backend defaults, matching the Sheaf importer. + */ +private fun buildOptionsJson(opts: OpenPluralImportOptions): String { + val parts = listOf( + "\"system_profile\":${opts.systemProfile}", + "\"fronts\":${opts.fronts}", + "\"groups\":${opts.groups}", + "\"tags\":${opts.tags}", + "\"custom_fields\":${opts.customFields}", + ) + return parts.joinToString(",", prefix = "{", postfix = "}") +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt index 8c300af..a449ce1 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt @@ -574,6 +574,7 @@ fun DataSettingsScreen( onNavigateToTupperboxImport: () -> Unit, onNavigateToPluralSpaceImport: () -> Unit, onNavigateToPrismImport: () -> Unit, + onNavigateToOpenPluralImport: () -> Unit, onNavigateToImportHistory: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { @@ -692,6 +693,13 @@ fun DataSettingsScreen( onClick = onNavigateToPrismImport, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + SettingItem( + icon = Icons.Outlined.Upload, + title = "Import from OpenPlural", + subtitle = "Use an OpenPlural .json or .openplural.zip export", + onClick = onNavigateToOpenPluralImport, + ) + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.Outlined.History, title = "Import history", From 0058546d3d5760ccb8ebfac1abb031d78c854689 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:51:45 -0400 Subject: [PATCH 02/12] feat(export): format selector + OpenPlural + full backup with images Brings the export surface to web parity. The old Data settings 'Export All Data' item (sync Sheaf JSON only) becomes a dedicated Export data screen: - Format selector: Sheaf native or OpenPlural v0.1. - Export JSON only: synchronous GET /v1/export?format=, streamed straight to the chosen file (.json or .openplural.json). - Build full backup (with images): the async export-job flow that didn't exist on Android before. Step-up auth (password, plus TOTP when the account has 2FA), POST /v1/export/jobs, then a Recent backups list that polls while a job builds and offers a streamed download (.zip / .openplural.zip) when ready. New API methods (export job create/list/get/download, format param on the sync export) and ExportJobRequest/ExportJobRead models. Removed the now-dead inline JSON-export plumbing from SettingsViewModel + the Data screen. --- .../lupine/sheaf/data/api/SheafApiService.kt | 29 +- .../systems/lupine/sheaf/data/model/Models.kt | 31 ++ .../java/systems/lupine/sheaf/ui/SheafApp.kt | 7 + .../sheaf/ui/export/ExportDataScreen.kt | 297 ++++++++++++++++++ .../sheaf/ui/export/ExportDataViewModel.kt | 174 ++++++++++ .../ui/settings/SettingsCategoryScreens.kt | 30 +- .../sheaf/ui/settings/SettingsViewModel.kt | 12 - 7 files changed, 543 insertions(+), 37 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataViewModel.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index eae3c18..ee6bdfe 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -421,8 +421,35 @@ interface SheafApiService { // ── Export ──────────────────────────────────────────────────────────────── + /** + * Synchronous JSON export. [format] is "sheaf" (native, full-fidelity + * re-import) or "openplural" (v0.1 interchange, uri-only assets). No + * step-up; this is metadata only, no image bytes. + */ @GET("/v1/export") - suspend fun exportAll(): okhttp3.ResponseBody + suspend fun exportAll(@Query("format") format: String = "sheaf"): okhttp3.ResponseBody + + /** + * Enqueue an async full-backup job (JSON + image bytes, zipped). Body + * carries the format ("sheaf_native" or "openplural") and step-up + * credentials (password, plus totp_code when the account has 2FA). The + * server refuses API-key auth and allows only one in-flight job per user. + * Returns 202 + the pending [ExportJobRead]; poll [getExportJob] or + * refresh [listExportJobs] until status is "done", then [downloadExportJob]. + */ + @POST("/v1/export/jobs") + suspend fun createExportJob(@Body body: ExportJobRequest): ExportJobRead + + @GET("/v1/export/jobs") + suspend fun listExportJobs(): List + + @GET("/v1/export/jobs/{id}") + suspend fun getExportJob(@Path("id") id: String): ExportJobRead + + /** Stream the finished backup zip. @Streaming so the zip isn't buffered. */ + @Streaming + @GET("/v1/export/jobs/{id}/download") + suspend fun downloadExportJob(@Path("id") id: String): okhttp3.ResponseBody // ── Imports (preview synchronous, submit async) ────────────────────────── // diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index a5b2539..95856b1 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -965,6 +965,37 @@ object ImportJobSource { const val OPENPLURAL_FILE = "openplural_file" } +// ── Export ────────────────────────────────────────────────────────────────── + +/** Async full-backup (with images) request. Step-up: password always, plus + * totpCode when the account has 2FA. format is "sheaf_native" or + * "openplural" (note: the synchronous JSON export uses "sheaf"/"openplural"). */ +@JsonClass(generateAdapter = true) +data class ExportJobRequest( + @Json(name = "include_images") val includeImages: Boolean = true, + val format: String, + val password: String, + @Json(name = "totp_code") val totpCode: String? = null, +) + +@JsonClass(generateAdapter = true) +data class ExportJobRead( + val id: String, + @Json(name = "include_images") val includeImages: Boolean, + val format: String, + // pending | running | done | failed | expired + val status: String, + @Json(name = "requested_at") val requestedAt: String, + @Json(name = "started_at") val startedAt: String? = null, + @Json(name = "completed_at") val completedAt: String? = null, + @Json(name = "expires_at") val expiresAt: String? = null, + @Json(name = "file_size_bytes") val fileSizeBytes: Long? = null, + val error: String? = null, +) { + val isTerminal: Boolean get() = status == "done" || status == "failed" || status == "expired" + val isDownloadable: Boolean get() = status == "done" +} + /** * Lighter shape of [ImportJobRead] for the history list. Drops the * events array which can run 10k entries on a large failing import; diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index b7147cf..5c90e2f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -93,6 +93,7 @@ object Routes { const val ADMIN_USER_DETAIL = "settings/admin/user/{userId}" const val SYSTEM_SAFETY = "settings/safety" const val FILES = "settings/files" + const val EXPORT_DATA = "settings/export" const val DEBUG = "settings/debug" // Categorized settings detail screens. const val SETTINGS_ACCOUNT = "settings/account" @@ -485,6 +486,7 @@ fun SheafApp( systems.lupine.sheaf.ui.settings.DataSettingsScreen( onNavigateUp = { navController.navigateUp() }, onNavigateToFiles = { navController.navigate(Routes.FILES) }, + onNavigateToExportData = { navController.navigate(Routes.EXPORT_DATA) }, onNavigateToSpImport = { navController.navigate(Routes.SP_IMPORT) }, onNavigateToSheafImport = { navController.navigate(Routes.SHEAF_IMPORT) }, onNavigateToPkFileImport = { navController.navigate(Routes.PK_IMPORT) }, @@ -552,6 +554,11 @@ fun SheafApp( onNavigateUp = { navController.navigateUp() }, ) } + composable(Routes.EXPORT_DATA) { + systems.lupine.sheaf.ui.export.ExportDataScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } composable(Routes.IMPORT_HISTORY) { systems.lupine.sheaf.ui.imports.ImportHistoryScreen( onNavigateUp = { navController.navigateUp() }, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt new file mode 100644 index 0000000..9772e67 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt @@ -0,0 +1,297 @@ +package systems.lupine.sheaf.ui.export + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import systems.lupine.sheaf.data.model.ExportJobRead +import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.SectionHeader +import systems.lupine.sheaf.ui.components.SheafTopAppBar +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExportDataScreen( + onNavigateUp: () -> Unit, + viewModel: ExportDataViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + + val jsonSaveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> uri?.let { viewModel.exportJsonTo(it) } } + + var pendingDownloadJobId by remember { mutableStateOf(null) } + val zipSaveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + val jobId = pendingDownloadJobId + pendingDownloadJobId = null + if (uri != null && jobId != null) viewModel.downloadJobTo(jobId, uri) + } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Export data") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.error != null) ErrorBanner(state.error!!) + if (state.message != null) { + Text(state.message!!, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + } + + SectionHeader("Format") + ExportFormat.entries.forEach { fmt -> + FormatRow( + format = fmt, + selected = state.format == fmt, + onSelect = { viewModel.setFormat(fmt) }, + ) + } + + HorizontalDivider() + SectionHeader("Download") + + Button( + onClick = { + val ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")) + jsonSaveLauncher.launch(viewModel.jsonFileName(ts)) + }, + enabled = !state.isExportingJson, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isExportingJson) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + Spacer(Modifier.width(8.dp)) + } + Text("Export JSON only") + } + Text( + "A metadata-only JSON file. Fast, but does not include image files.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton( + onClick = { viewModel.openStepUp() }, + enabled = !state.isSubmittingJob, + modifier = Modifier.fillMaxWidth(), + ) { Text("Build full backup (with images)") } + Text( + "Builds a zip with your images in the background, then appears below to download. Requires your password.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (state.jobs.isNotEmpty() || state.isLoadingJobs) { + HorizontalDivider() + SectionHeader("Recent backups") + if (state.isLoadingJobs && state.jobs.isEmpty()) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + state.jobs.forEach { job -> + BackupRow( + job = job, + isDownloading = state.downloadingJobId == job.id, + onDownload = { + pendingDownloadJobId = job.id + zipSaveLauncher.launch(viewModel.fileNameForJob(job)) + }, + ) + } + } + } + } + + if (state.showStepUp) { + StepUpDialog( + totpEnabled = state.totpEnabled, + isSubmitting = state.isSubmittingJob, + error = state.stepUpError, + onConfirm = { password, totp -> viewModel.requestFullBackup(password, totp) }, + onDismiss = { viewModel.dismissStepUp() }, + ) + } +} + +@Composable +private fun FormatRow(format: ExportFormat, selected: Boolean, onSelect: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onSelect) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + RadioButton(selected = selected, onClick = onSelect) + Column(Modifier.padding(start = 4.dp, top = 10.dp)) { + Text(format.label, style = MaterialTheme.typography.bodyLarge) + Text( + format.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun BackupRow(job: ExportJobRead, isDownloading: Boolean, onDownload: () -> Unit) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + statusLabel(job), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = when (job.status) { + "failed", "expired" -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + }, + ) + val sub = buildList { + add(if (job.format == "openplural") "OpenPlural" else "Sheaf") + formatDate(job.requestedAt)?.let { add(it) } + job.fileSizeBytes?.let { add(formatSize(it)) } + }.joinToString(" · ") + Text(sub, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (job.status == "failed" && job.error != null) { + Text(job.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + when { + job.status == "pending" || job.status == "running" -> + CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) + job.isDownloadable -> { + if (isDownloading) { + CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) + } else { + IconButton(onClick = onDownload) { + Icon(Icons.Outlined.CloudDownload, contentDescription = "Download backup") + } + } + } + } + } + } +} + +@Composable +private fun StepUpDialog( + totpEnabled: Boolean, + isSubmitting: Boolean, + error: String?, + onConfirm: (password: String, totp: String?) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + var totp by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { if (!isSubmitting) onDismiss() }, + title = { Text("Confirm it's you") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Building a full backup exports everything you have, including images. Enter your password to continue.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + if (totpEnabled) { + OutlinedTextField( + value = totp, + onValueChange = { totp = it }, + label = { Text("Authenticator code") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + if (error != null) { + Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(password, totp.takeIf { totpEnabled }) }, + enabled = !isSubmitting && password.isNotBlank() && (!totpEnabled || totp.isNotBlank()), + ) { + if (isSubmitting) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Build backup") + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isSubmitting) { Text("Cancel") } + }, + ) +} + +private fun statusLabel(job: ExportJobRead): String = when (job.status) { + "pending" -> "Queued" + "running" -> "Building…" + "done" -> "Ready to download" + "failed" -> "Failed" + "expired" -> "Expired" + else -> job.status +} + +private fun formatDate(iso: String): String? = runCatching { + OffsetDateTime.parse(iso).toLocalDateTime().format(DateTimeFormatter.ofPattern("MMM d, HH:mm")) +}.getOrNull() + +private fun formatSize(bytes: Long): String = when { + bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0) + bytes >= 1_000 -> "%.0f KB".format(bytes / 1_000.0) + else -> "$bytes B" +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataViewModel.kt new file mode 100644 index 0000000..844b0d0 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataViewModel.kt @@ -0,0 +1,174 @@ +package systems.lupine.sheaf.ui.export + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.model.ExportJobRead +import systems.lupine.sheaf.data.model.ExportJobRequest +import systems.lupine.sheaf.util.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** Interchange format the export is rendered in. */ +enum class ExportFormat(val label: String, val description: String) { + SHEAF("Sheaf", "Full-fidelity backup, re-importable into another Sheaf instance."), + OPENPLURAL("OpenPlural", "OpenPlural v0.1, for interchange with other compatible apps. JSON here is uri-only; the full backup zip carries image bytes."), +} + +data class ExportUiState( + val format: ExportFormat = ExportFormat.SHEAF, + val jobs: List = emptyList(), + val isLoadingJobs: Boolean = false, + val totpEnabled: Boolean = false, + val isExportingJson: Boolean = false, + val isSubmittingJob: Boolean = false, + val downloadingJobId: String? = null, + val showStepUp: Boolean = false, + val stepUpError: String? = null, + val error: String? = null, + val message: String? = null, +) + +@HiltViewModel +class ExportDataViewModel @Inject constructor( + private val api: SheafApiService, + @ApplicationContext private val context: Context, +) : ViewModel() { + + private val _state = MutableStateFlow(ExportUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadTotpEnabled() + refreshJobs(poll = true) + } + + fun setFormat(format: ExportFormat) = _state.update { it.copy(format = format) } + + private fun loadTotpEnabled() { + viewModelScope.launch { + runCatching { api.getMe() } + .onSuccess { user -> _state.update { it.copy(totpEnabled = user.totpEnabled == true) } } + } + } + + /** Synchronous JSON export, streamed straight into [uri]. */ + fun exportJsonTo(uri: Uri) { + viewModelScope.launch { + _state.update { it.copy(isExportingJson = true, error = null, message = null) } + val formatParam = if (_state.value.format == ExportFormat.OPENPLURAL) "openplural" else "sheaf" + runCatching { + withContext(Dispatchers.IO) { + api.exportAll(format = formatParam).byteStream().use { input -> + context.contentResolver.openOutputStream(uri)?.use { output -> + input.copyTo(output) + } ?: error("Couldn't open the chosen location") + } + } + } + .onSuccess { _state.update { it.copy(isExportingJson = false, message = "Export saved") } } + .onFailure { e -> _state.update { it.copy(isExportingJson = false, error = e.toUserMessage("Export failed")) } } + } + } + + /** Suggested filename for the synchronous JSON export. */ + fun jsonFileName(timestamp: String): String = + if (_state.value.format == ExportFormat.OPENPLURAL) "sheaf-export-$timestamp.openplural.json" + else "sheaf-export-$timestamp.json" + + fun openStepUp() = _state.update { it.copy(showStepUp = true, stepUpError = null) } + fun dismissStepUp() = _state.update { it.copy(showStepUp = false, stepUpError = null) } + + /** Enqueue the async full-backup-with-images job after step-up auth. */ + fun requestFullBackup(password: String, totpCode: String?) { + viewModelScope.launch { + _state.update { it.copy(isSubmittingJob = true, stepUpError = null) } + val format = if (_state.value.format == ExportFormat.OPENPLURAL) "openplural" else "sheaf_native" + runCatching { + api.createExportJob( + ExportJobRequest( + includeImages = true, + format = format, + password = password, + totpCode = totpCode?.takeIf { it.isNotBlank() }, + ) + ) + } + .onSuccess { + _state.update { + it.copy( + isSubmittingJob = false, + showStepUp = false, + message = "Backup queued. We'll build it in the background; check back here.", + ) + } + refreshJobs(poll = true) + } + .onFailure { e -> + // Keep the dialog open on an auth failure so the user can retry. + _state.update { it.copy(isSubmittingJob = false, stepUpError = e.toUserMessage("Couldn't start the backup")) } + } + } + } + + /** Stream a finished backup zip into [uri]. */ + fun downloadJobTo(jobId: String, uri: Uri) { + viewModelScope.launch { + _state.update { it.copy(downloadingJobId = jobId, error = null, message = null) } + runCatching { + withContext(Dispatchers.IO) { + api.downloadExportJob(jobId).byteStream().use { input -> + context.contentResolver.openOutputStream(uri)?.use { output -> + input.copyTo(output) + } ?: error("Couldn't open the chosen location") + } + } + } + .onSuccess { _state.update { it.copy(downloadingJobId = null, message = "Backup saved") } } + .onFailure { e -> _state.update { it.copy(downloadingJobId = null, error = e.toUserMessage("Download failed")) } } + } + } + + fun refreshJobs(poll: Boolean = false) { + viewModelScope.launch { + _state.update { it.copy(isLoadingJobs = it.jobs.isEmpty()) } + runCatching { api.listExportJobs() } + .onSuccess { jobs -> + _state.update { it.copy(isLoadingJobs = false, jobs = jobs) } + if (poll && jobs.any { it.status == "pending" || it.status == "running" }) { + pollActiveJobs() + } + } + .onFailure { _state.update { it.copy(isLoadingJobs = false) } } + } + } + + /** Poll while any job is still building, then stop. */ + private fun pollActiveJobs() { + viewModelScope.launch { + while (_state.value.jobs.any { it.status == "pending" || it.status == "running" }) { + delay(POLL_INTERVAL_MS) + val jobs = runCatching { api.listExportJobs() }.getOrNull() ?: break + _state.update { it.copy(jobs = jobs) } + } + } + } + + fun clearMessages() = _state.update { it.copy(error = null, message = null) } + + fun fileNameForJob(job: ExportJobRead): String = + if (job.format == "openplural") "sheaf-export-${job.id}.openplural.zip" + else "sheaf-export-${job.id}.zip" + + companion object { + private const val POLL_INTERVAL_MS: Long = 5000 + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt index a449ce1..e1dc90a 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt @@ -567,6 +567,7 @@ fun SafetyCategoryScreen( fun DataSettingsScreen( onNavigateUp: () -> Unit, onNavigateToFiles: () -> Unit, + onNavigateToExportData: () -> Unit, onNavigateToSpImport: () -> Unit, onNavigateToSheafImport: () -> Unit, onNavigateToPkFileImport: () -> Unit, @@ -579,29 +580,10 @@ fun DataSettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - val context = LocalContext.current var showDeleteOrphansDialog by remember { mutableStateOf(false) } - var pendingExportJson by remember { mutableStateOf(null) } - val saveFileLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/json") - ) { uri -> - uri?.let { - pendingExportJson?.let { json -> - context.contentResolver.openOutputStream(uri)?.use { it.write(json.toByteArray()) } - } - } - pendingExportJson = null - viewModel.clearExport() - } - - LaunchedEffect(state.exportJson) { - state.exportJson?.let { json -> - pendingExportJson = json - val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")) - saveFileLauncher.launch("sheaf-export-$timestamp.json") - } - } + // Full export UI (format selector, JSON vs full-backup-with-images, recent + // backups) lives on its own ExportDataScreen; this screen just links to it. LaunchedEffect(state.orphanedFiles) { if (state.orphanedFiles != null && state.orphanedFiles!!.isNotEmpty()) { @@ -639,9 +621,9 @@ fun DataSettingsScreen( HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.Outlined.Download, - title = "Export All Data", - subtitle = "Download a full JSON backup", - onClick = { viewModel.exportData() }, + title = "Export data", + subtitle = "JSON or full backup, in Sheaf or OpenPlural format", + onClick = onNavigateToExportData, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt index f11ae44..acaccfc 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsViewModel.kt @@ -41,7 +41,6 @@ data class SettingsUiState( val frontingCount: Int = 0, val isLoading: Boolean = false, val error: String? = null, - val exportJson: String? = null, // TOTP val totpStep: TotpStep = TotpStep.LOADING, val totpSetupResponse: TOTPSetupResponse? = null, @@ -239,16 +238,6 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.saveAppLock(enabled) } } - fun exportData() { - viewModelScope.launch { - runCatching { api.exportAll() } - .onSuccess { data -> - _state.update { it.copy(exportJson = data.string()) } - } - .onFailure { e -> _state.update { it.copy(error = e.toUserMessage()) } } - } - } - // ── TOTP ────────────────────────────────────────────────────────────────── fun startTotpSetup() { @@ -372,7 +361,6 @@ class SettingsViewModel @Inject constructor( fun clearVerificationEmailSent() { _state.update { it.copy(verificationEmailSent = false) } } - fun clearExport() { _state.update { it.copy(exportJson = null) } } fun clearError() { _state.update { it.copy(error = null) } } fun clearTotpError() { _state.update { it.copy(totpError = null) } } fun clearDeletionError() { _state.update { it.copy(deletionError = null) } } From 562e62ac3fbd39c7d131319ef013a1fd52acb3f9 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:05:58 -0400 Subject: [PATCH 03/12] feat(settings): Support screen; fix(history): block end-before-start front edits Web-parity follow-ups: - Support screen (Settings > Support): operator contact, service status, and policy links pulled from /v1/auth/config (all optional), plus static project source/issues and security-contact links. Mirrors web's Support page. - Front history editor now blocks saving an entry whose end time is not after its start (inline warning + disabled save), matching the web edit-front dialog. (Date entry on Android is already picker-based and day-clamped, so the web 'flag invalid typed date' fix has no analogue.) --- .../systems/lupine/sheaf/data/model/Models.kt | 8 ++ .../java/systems/lupine/sheaf/ui/SheafApp.kt | 7 + .../lupine/sheaf/ui/history/HistoryScreen.kt | 22 ++- .../sheaf/ui/settings/SettingsScreen.kt | 9 ++ .../lupine/sheaf/ui/support/SupportScreen.kt | 125 ++++++++++++++++++ .../sheaf/ui/support/SupportViewModel.kt | 38 ++++++ 6 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportViewModel.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 95856b1..e2fcb8e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -16,6 +16,14 @@ data class AuthConfig( @Json(name = "file_cdn_base") val fileCdnBase: String? = null, @Json(name = "captcha_provider") val captchaProvider: String? = null, @Json(name = "captcha_on_login") val captchaOnLogin: Boolean = false, + // Operator-configured contact + policy links, surfaced on the Support + // screen. All optional; the operator may set none of them. + @Json(name = "support_email") val supportEmail: String? = null, + @Json(name = "support_url") val supportUrl: String? = null, + @Json(name = "support_note") val supportNote: String? = null, + @Json(name = "status_url") val statusUrl: String? = null, + @Json(name = "terms_url") val termsUrl: String? = null, + @Json(name = "privacy_url") val privacyUrl: String? = null, ) @JsonClass(generateAdapter = true) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index 5c90e2f..675746c 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -95,6 +95,7 @@ object Routes { const val FILES = "settings/files" const val EXPORT_DATA = "settings/export" const val DEBUG = "settings/debug" + const val SUPPORT = "settings/support" // Categorized settings detail screens. const val SETTINGS_ACCOUNT = "settings/account" const val SETTINGS_ADMIN_ACTIVITY = "settings/account/admin-activity" @@ -347,6 +348,7 @@ fun SheafApp( onNavigateToSafety = { navController.navigate(Routes.SETTINGS_SAFETY) }, onNavigateToDanger = { navController.navigate(Routes.SETTINGS_DANGER) }, onNavigateToAdminPanel = { navController.navigate(Routes.ADMIN_PANEL) }, + onNavigateToSupport = { navController.navigate(Routes.SUPPORT) }, onNavigateToDebug = { navController.navigate(Routes.DEBUG) }, ) } @@ -617,6 +619,11 @@ fun SheafApp( composable(Routes.FILES) { systems.lupine.sheaf.ui.files.FilesScreen(onNavigateUp = { navController.navigateUp() }) } + composable(Routes.SUPPORT) { + systems.lupine.sheaf.ui.support.SupportScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } composable(Routes.DEBUG) { DebugScreen( onNavigateUp = { navController.navigateUp() }, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt index 09184d3..ff42cb2 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt @@ -866,12 +866,24 @@ private fun FrontEntrySheet( modifier = Modifier.fillMaxWidth(), ) + // Validate the range up front so an end-before-start entry can't + // be saved (mirrors the web edit-front dialog). Computed here so + // both the inline warning and the button's enabled state use it. + val zone = ZoneId.systemDefault() + val startInstant = LocalDateTime.of(startDate, startTime).atZone(zone).toInstant() + val endInstant = if (stillOngoing) null + else LocalDateTime.of(endDate, endTime).atZone(zone).toInstant() + val endBeforeStart = endInstant != null && !endInstant.isAfter(startInstant) + if (endBeforeStart) { + Text( + "The end time must be after the start time.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Button( onClick = { - val zone = ZoneId.systemDefault() - val startInstant = LocalDateTime.of(startDate, startTime).atZone(zone).toInstant() - val endInstant = if (stillOngoing) null - else LocalDateTime.of(endDate, endTime).atZone(zone).toInstant() val statusOut = customStatusDraft.trim().ifEmpty { null } onConfirm( selectedIds.toList(), @@ -880,7 +892,7 @@ private fun FrontEntrySheet( statusOut, ) }, - enabled = selectedIds.isNotEmpty(), + enabled = selectedIds.isNotEmpty() && !endBeforeStart, modifier = Modifier.fillMaxWidth(), ) { Text(if (isEditing) "Save Changes" else "Add Entry") } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt index ce81ad5..660f0a8 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsScreen.kt @@ -65,6 +65,7 @@ fun SettingsScreen( onNavigateToSafety: () -> Unit, onNavigateToDanger: () -> Unit, onNavigateToAdminPanel: () -> Unit, + onNavigateToSupport: () -> Unit, onNavigateToDebug: () -> Unit, settingsViewModel: SettingsViewModel = hiltViewModel(), authViewModel: AuthViewModel = hiltViewModel(), @@ -267,6 +268,14 @@ fun SettingsScreen( ) } + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + SettingItem( + icon = Icons.Outlined.HelpOutline, + title = "Support", + subtitle = "Contact, status, and policies", + onClick = onNavigateToSupport, + ) + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.Outlined.Warning, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt new file mode 100644 index 0000000..c725704 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt @@ -0,0 +1,125 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package systems.lupine.sheaf.ui.support + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.MonitorHeart +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import systems.lupine.sheaf.ui.components.SectionHeader +import systems.lupine.sheaf.ui.components.SheafTopAppBar + +@Composable +fun SupportScreen( + onNavigateUp: () -> Unit, + viewModel: SupportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + fun open(uri: String) { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) } + } + + val config = state.config + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Support") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + if (state.isLoading) { + Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + val hasOperatorContact = config != null && ( + config.supportEmail != null || config.supportUrl != null || + config.supportNote != null || config.statusUrl != null + ) + + if (hasOperatorContact) { + SectionHeader("Contact this instance", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + config?.supportNote?.let { note -> + Text( + note, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + config?.supportEmail?.let { email -> + LinkRow(Icons.Outlined.Email, "Email support", email) { open("mailto:$email") } + } + config?.supportUrl?.let { url -> + LinkRow(Icons.Outlined.Public, "Support site", url) { open(url) } + } + config?.statusUrl?.let { url -> + LinkRow(Icons.Outlined.MonitorHeart, "Service status", url) { open(url) } + } + } + + if (config?.termsUrl != null || config?.privacyUrl != null) { + SectionHeader("Policies", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + config.termsUrl?.let { url -> LinkRow(Icons.Outlined.Description, "Terms of service", url) { open(url) } } + config.privacyUrl?.let { url -> LinkRow(Icons.Outlined.Description, "Privacy policy", url) { open(url) } } + } + + // Static project links, independent of the operator's instance. + SectionHeader("Sheaf", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + LinkRow(Icons.Outlined.Code, "Source & issues", "github.com/sheaf-project") { + open("https://github.com/sheaf-project/android/issues") + } + LinkRow(Icons.Outlined.Shield, "Report a security issue", "security@sheaf.sh") { + open("mailto:security@sheaf.sh") + } + + Spacer(Modifier.height(24.dp)) + } + } +} + +@Composable +private fun LinkRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) { + Surface(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = { Text(subtitle, maxLines = 1) }, + leadingContent = { Icon(icon, contentDescription = null) }, + trailingContent = { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) }, + ) + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportViewModel.kt new file mode 100644 index 0000000..d63ea8e --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportViewModel.kt @@ -0,0 +1,38 @@ +package systems.lupine.sheaf.ui.support + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.model.AuthConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SupportUiState( + val isLoading: Boolean = true, + val config: AuthConfig? = null, +) + +/** + * Backs the Support screen. Pulls the operator's public config (support + * contact, status page, policy links) from the same `/v1/auth/config` + * endpoint the login screen uses; everything there is optional. + */ +@HiltViewModel +class SupportViewModel @Inject constructor( + private val api: SheafApiService, +) : ViewModel() { + + private val _state = MutableStateFlow(SupportUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + val config = runCatching { api.getAuthConfig() }.getOrNull() + _state.value = SupportUiState(isLoading = false, config = config) + } + } +} From 24e399782354a5424bbb90175c699f69e3e3e6b0 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:06:36 -0400 Subject: [PATCH 04/12] changelog: OpenPlural import/export, export screen, support, front-edit guard --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f214ff7..d02aabf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to the Sheaf Android client are recorded here. Format loosel follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses semantic versioning (`MAJOR.MINOR.PATCH`). +## [Unreleased] + +### Added + +- **Import and export OpenPlural.** OpenPlural v0.1 is an interchange + format shared across plural apps. You can now import an OpenPlural + `.json` or `.openplural.zip` (with images) export from another app, and + export your own data in OpenPlural format. + +- **Export got a proper home.** Settings now has an Export data screen + with a format picker (Sheaf or OpenPlural), a quick JSON-only export, + and a "full backup with images" option. The full backup builds in the + background (confirmed with your password, plus your authenticator code + if you use 2FA) and then appears in a Recent backups list to download. + +- **Support screen.** Settings now has a Support entry showing your + instance operator's contact, service status, and policy links (when + they've set them), plus links to the project source and security + contact. + +### Fixed + +- **Front history editor rejects an end before the start.** Editing a + front entry now warns and blocks saving when the end time isn't after + the start time, instead of saving a backwards range. + ## [1.1.1] - 2026-06-19 ### Added From 391a698fa58bd343b55ed201e94ccfb3bab48009 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:55:52 -0400 Subject: [PATCH 05/12] feat(support): render operator custom support text (markdown) The backend now exposes an operator-authored support_custom_text on /v1/auth/config (HTML stripped server-side). Render it as markdown at the top of the Support screen, matching web. --- CHANGELOG.md | 8 ++++---- .../java/systems/lupine/sheaf/data/model/Models.kt | 3 +++ .../systems/lupine/sheaf/ui/support/SupportScreen.kt | 11 +++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d02aabf..b148607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,10 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). background (confirmed with your password, plus your authenticator code if you use 2FA) and then appears in a Recent backups list to download. -- **Support screen.** Settings now has a Support entry showing your - instance operator's contact, service status, and policy links (when - they've set them), plus links to the project source and security - contact. +- **Support screen.** Settings now has a Support entry showing any + custom message your instance operator has set, plus their contact, + service status, and policy links (when configured), and links to the + project source and security contact. ### Fixed diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index e2fcb8e..e9d0404 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -21,6 +21,9 @@ data class AuthConfig( @Json(name = "support_email") val supportEmail: String? = null, @Json(name = "support_url") val supportUrl: String? = null, @Json(name = "support_note") val supportNote: String? = null, + // Operator-authored markdown shown at the top of the Support screen. + // Server strips any raw HTML before sending, so it's safe to render. + @Json(name = "support_custom_text") val supportCustomText: String? = null, @Json(name = "status_url") val statusUrl: String? = null, @Json(name = "terms_url") val termsUrl: String? = null, @Json(name = "privacy_url") val privacyUrl: String? = null, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt index c725704..ff3b0ea 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/support/SupportScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import systems.lupine.sheaf.ui.components.SectionHeader +import systems.lupine.sheaf.ui.components.SheafMarkdownText import systems.lupine.sheaf.ui.components.SheafTopAppBar @Composable @@ -66,6 +67,16 @@ fun SupportScreen( } } + // Operator-authored markdown blurb (HTML already stripped server-side). + config?.supportCustomText?.takeIf { it.isNotBlank() }?.let { md -> + SheafMarkdownText( + markdown = md, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + HorizontalDivider() + } + val hasOperatorContact = config != null && ( config.supportEmail != null || config.supportUrl != null || config.supportNote != null || config.statusUrl != null From 4656bde9e9aeeed6f9a9ac7714bd41bad2be3a15 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:02:34 -0400 Subject: [PATCH 06/12] feat(editor): formatting-help popup in the markdown toolbar Adds a help button to the shared MarkdownBodyEditor toolbar (member bios, journals, group + system descriptions) opening a quick markdown reference. Leads with the line-break gotcha: a single newline doesn't break a line; use a blank line for a new paragraph or two trailing spaces / a backslash for a soft break. --- .../sheaf/ui/components/MarkdownBodyEditor.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/MarkdownBodyEditor.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/MarkdownBodyEditor.kt index 5b0d58c..121b304 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/MarkdownBodyEditor.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/MarkdownBodyEditor.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.FormatListBulleted import androidx.compose.material.icons.filled.AddPhotoAlternate @@ -22,6 +23,7 @@ import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.FormatBold import androidx.compose.material.icons.filled.FormatItalic import androidx.compose.material.icons.filled.FormatListNumbered +import androidx.compose.material.icons.filled.HelpOutline import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.Title @@ -78,6 +80,7 @@ fun MarkdownBodyEditor( } } var showImagePicker by remember { mutableStateOf(false) } + var showFormattingHelp by remember { mutableStateOf(false) } // Insert any pending image markdown into the body at the current cursor, // then clear it so the next upload retriggers cleanly. @@ -100,6 +103,7 @@ fun MarkdownBodyEditor( showImagePicker = true imagePicker?.onLoadAvailableImages?.invoke() }, + onHelp = { showFormattingHelp = true }, ) OutlinedTextField( @@ -119,6 +123,10 @@ fun MarkdownBodyEditor( } } + if (showFormattingHelp) { + MarkdownHelpDialog(onDismiss = { showFormattingHelp = false }) + } + if (showImagePicker && imagePicker != null) { val activityImagePicker = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia() @@ -153,6 +161,7 @@ private fun FormatToolbar( isUploadingImage: Boolean, onAction: ((TextFieldValue) -> TextFieldValue) -> Unit, onPickImage: () -> Unit, + onHelp: () -> Unit, ) { val scrollState = rememberScrollState() val btnSize = 38.dp @@ -204,6 +213,68 @@ private fun FormatToolbar( } } } + IconButton(onClick = onHelp, modifier = Modifier.size(btnSize)) { + Icon(Icons.Default.HelpOutline, contentDescription = "Formatting help", modifier = Modifier.size(iconSize)) + } + } +} + +// Quick reference for the markdown the renderer supports. Triggered from the +// editor toolbar. The line-break note is the headline item: people expect a +// single newline to break a line, but markdown needs a blank line for a new +// paragraph and two trailing spaces (or a backslash) for a soft break. +@Composable +private fun MarkdownHelpDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { TextButton(onClick = onDismiss) { Text("Got it") } }, + title = { Text("Formatting") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MarkdownHelpRow("**bold**", "Bold") + MarkdownHelpRow("*italic*", "Italic") + MarkdownHelpRow("# Heading", "Heading (more # = smaller)") + MarkdownHelpRow("- item", "Bulleted list") + MarkdownHelpRow("1. item", "Numbered list") + MarkdownHelpRow("> quote", "Quote") + MarkdownHelpRow("[text](https://…)", "Link") + MarkdownHelpRow("`code`", "Inline code") + MarkdownHelpRow("``` … ```", "Code block") + HorizontalDivider() + Text("Line breaks", style = MaterialTheme.typography.labelLarge) + Text( + "Leave a blank line between paragraphs. For a single line break " + + "without starting a new paragraph, end the line with two spaces, " + + "or a backslash (\\).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) +} + +@Composable +private fun MarkdownHelpRow(syntax: String, label: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + syntax, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace), + modifier = Modifier.weight(1f), + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) } } From 80452ff68d81bac28dc372d6a5f93074db65a988 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:08:59 -0400 Subject: [PATCH 07/12] feat(members): archive / unarchive Members can now be archived: a reversible soft-hide (distinct from delete, no grace period). The list endpoint still returns archived members, so the client filters them out of the main roster and shows them in a collapsible Archived section with an Unarchive action; archive lives in the per-member long-press menu. Archiving tries with no credentials and, only if the instance's archive safety category is on, prompts for password/TOTP and retries. MemberRead gains archived_at. --- .../lupine/sheaf/data/api/SheafApiService.kt | 12 ++ .../systems/lupine/sheaf/data/model/Models.kt | 14 ++ .../lupine/sheaf/ui/members/MembersScreen.kt | 128 +++++++++++++++++- .../sheaf/ui/members/MembersViewModel.kt | 56 ++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index ee6bdfe..9ad5f27 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -182,6 +182,18 @@ interface SheafApiService { @Body body: MemberDeleteConfirm = MemberDeleteConfirm(), ): Response + /** Archive (reversible soft-hide). [body] carries step-up credentials only + * when the system's archive safety category is on; an empty body is fine + * otherwise (the server then 4xxs and the caller retries with creds). */ + @POST("/v1/members/{id}/archive") + suspend fun archiveMember( + @Path("id") id: String, + @Body body: MemberArchiveBody = MemberArchiveBody(), + ): MemberRead + + @POST("/v1/members/{id}/unarchive") + suspend fun unarchiveMember(@Path("id") id: String): MemberRead + @GET("/v1/members/{id}/revisions") suspend fun listMemberBioRevisions(@Path("id") id: String): List diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index e9d0404..cfb4be6 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -412,8 +412,13 @@ data class MemberRead( @Json(name = "created_at") val createdAt: String, @Json(name = "updated_at") val updatedAt: String, val emoji: String? = null, + // Set when the member is archived: a reversible soft-hide. The list + // endpoint still returns archived members, so the client filters them + // out of the main roster and surfaces them separately. + @Json(name = "archived_at") val archivedAt: String? = null, ) { val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name + val isArchived: Boolean get() = archivedAt != null val initials: String get() = displayNameOrName .split("\\s+".toRegex()) .take(2) @@ -436,6 +441,15 @@ data class MemberCreate( val note: String? = null, ) +/** Optional step-up credentials for archiving a member. Only consulted when + * the system's "archive" safety category is enabled; an empty body is fine + * otherwise. */ +@JsonClass(generateAdapter = true) +data class MemberArchiveBody( + val password: String? = null, + @Json(name = "totp_code") val totpCode: String? = null, +) + @JsonClass(generateAdapter = true) data class MemberUpdate( val name: String? = null, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 8c82d29..8d8d128 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -23,6 +23,7 @@ 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.* +import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.* import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -36,6 +37,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -62,6 +64,7 @@ fun MembersScreen( val state by viewModel.state.collectAsState() var contextMenuMember by remember { mutableStateOf(null) } var memberToDelete by remember { mutableStateOf(null) } + var showArchived by remember { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -77,10 +80,15 @@ fun MembersScreen( val scope = rememberCoroutineScope() val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() + // The list endpoint returns archived members too; split them so the main + // roster stays clean and archived members surface in their own section. + val activeMembers = remember(state.members) { state.members.filter { !it.isArchived } } + val archivedMembers = remember(state.members) { state.members.filter { it.isArchived } } + val query = textFieldState.text.toString() - val filteredMembers = remember(query, state.members) { - if (query.isBlank()) state.members - else state.members.filter { + val filteredMembers = remember(query, activeMembers) { + if (query.isBlank()) activeMembers + else activeMembers.filter { it.displayNameOrName.contains(query, ignoreCase = true) || it.pronouns?.contains(query, ignoreCase = true) == true } @@ -214,7 +222,7 @@ fun MembersScreen( ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(state.members, key = { it.id }) { member -> + items(activeMembers, key = { it.id }) { member -> Box { MemberListItem( member = member, @@ -256,6 +264,14 @@ fun MembersScreen( }, ) HorizontalDivider() + DropdownMenuItem( + text = { Text("Archive member") }, + leadingIcon = { Icon(Icons.Outlined.Archive, contentDescription = null) }, + onClick = { + viewModel.archiveMember(member.id) + contextMenuMember = null + }, + ) DropdownMenuItem( text = { Text("Remove member", color = MaterialTheme.colorScheme.error) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, @@ -267,6 +283,47 @@ fun MembersScreen( } } } + + // Archived members, collapsed by default. + if (archivedMembers.isNotEmpty()) { + item(key = "archived-header") { + Surface( + onClick = { showArchived = !showArchived }, + color = Color.Transparent, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (showArchived) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Archived (${archivedMembers.size})", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (showArchived) { + items(archivedMembers, key = { "archived-${it.id}" }) { member -> + MemberListItem( + member = member, + onClick = { onMemberClick(member.id) }, + trailing = { + TextButton(onClick = { viewModel.unarchiveMember(member.id) }) { + Text("Unarchive") + } + }, + ) + } + } + } } } } @@ -292,6 +349,69 @@ fun MembersScreen( } } + // Only shown when the server demanded step-up auth to archive (the + // system's archive safety category is on). + state.archiveAuthFor?.let { memberId -> + ArchiveAuthDialog( + isArchiving = state.isArchiving, + error = state.archiveError, + onConfirm = { password, totp -> viewModel.archiveMember(memberId, password, totp) }, + onDismiss = { viewModel.cancelArchiveAuth() }, + ) + } +} + +@Composable +private fun ArchiveAuthDialog( + isArchiving: Boolean, + error: String?, + onConfirm: (password: String, totp: String?) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + var totp by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { if (!isArchiving) onDismiss() }, + title = { Text("Confirm archive") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "This instance requires re-authentication to archive a member.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = totp, + onValueChange = { totp = it }, + label = { Text("Authenticator code (if enabled)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(password, totp.ifBlank { null }) }, + enabled = !isArchiving && password.isNotBlank(), + ) { + if (isArchiving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + else Text("Archive") + } + }, + dismissButton = { TextButton(onClick = onDismiss, enabled = !isArchiving) { Text("Cancel") } }, + ) } // ── Member detail / edit / create ───────────────────────────────────────────── diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt index 4ebfecf..5798cdd 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt @@ -34,6 +34,11 @@ data class MembersUiState( val isDeleting: Boolean = false, val deleteError: String? = null, val deleteCompleted: Boolean = false, + // Archive: set to the member id when the server asked for step-up auth + // (the system's archive safety category is on); drives the prompt. + val archiveAuthFor: String? = null, + val archiveError: String? = null, + val isArchiving: Boolean = false, ) @HiltViewModel @@ -145,6 +150,57 @@ class MembersViewModel @Inject constructor( } } + /** + * Archive a member. Tries with no credentials first; if the system's + * archive safety category is on the server answers 4xx, and we surface a + * step-up prompt ([archiveAuthFor]) and retry with the supplied password + * (+ TOTP). Archiving is reversible, so no extra confirm beyond that. + */ + fun archiveMember(memberId: String, password: String? = null, totpCode: String? = null) { + viewModelScope.launch { + _state.update { it.copy(isArchiving = true, archiveError = null) } + runCatching { + api.archiveMember( + memberId, + MemberArchiveBody(password?.ifBlank { null }, totpCode?.ifBlank { null }), + ) + } + .onSuccess { + _state.update { it.copy(isArchiving = false, archiveAuthFor = null) } + load() + } + .onFailure { e -> + if (e is retrofit2.HttpException && e.code() in listOf(400, 403)) { + _state.update { + it.copy( + isArchiving = false, + archiveAuthFor = memberId, + archiveError = if (password != null) "Incorrect password or authenticator code" else null, + ) + } + } else { + _state.update { + it.copy( + isArchiving = false, + archiveAuthFor = null, + error = e.toUserMessage("Couldn't archive member"), + ) + } + } + } + } + } + + fun unarchiveMember(memberId: String) { + viewModelScope.launch { + runCatching { api.unarchiveMember(memberId) } + .onSuccess { load() } + .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Couldn't unarchive member")) } } + } + } + + fun cancelArchiveAuth() { _state.update { it.copy(archiveAuthFor = null, archiveError = null) } } + fun deleteMember(memberId: String, password: String? = null, totpCode: String? = null) { viewModelScope.launch { _state.update { it.copy(isDeleting = true, deleteError = null, deleteCompleted = false) } From 2210217d6715d9ba12f24687917902c515a71c06 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:14:56 -0400 Subject: [PATCH 08/12] feat(groups): subgroups (nested groups) The group model already carried parent_id; wire it into the UI. The group editor gets a Parent group picker (excluding the group itself and its descendants to prevent cycles; the server also caps nesting depth), and the groups list now renders hierarchically, indenting subgroups under their parent. --- .../lupine/sheaf/ui/groups/GroupsScreen.kt | 98 ++++++++++++++++++- .../lupine/sheaf/ui/groups/GroupsViewModel.kt | 15 +++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt index ac8e4f5..01bd58c 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsScreen.kt @@ -90,9 +90,11 @@ fun GroupsScreen( ), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - items(state.groups, key = { it.id }) { group -> + val ordered = orderGroupsHierarchically(state.groups) + items(ordered, key = { it.first.id }) { (group, depth) -> GroupCard( group = group, + depth = depth, expanded = group.id in state.expanded, members = state.groupMembers[group.id], loading = group.id in state.loadingMembers, @@ -109,6 +111,7 @@ fun GroupsScreen( @Composable private fun GroupCard( group: systems.lupine.sheaf.data.model.GroupRead, + depth: Int, expanded: Boolean, members: List?, loading: Boolean, @@ -119,7 +122,9 @@ private fun GroupCard( val accent = parseColor(group.color ?: "#534AB7") ?: MaterialTheme.colorScheme.primary Card( onClick = onToggleExpand, - modifier = Modifier.fillMaxWidth(), + // Indent subgroups under their parent. Capped so deep nesting stays + // usable on a narrow screen. + modifier = Modifier.fillMaxWidth().padding(start = (minOf(depth, 4) * 16).dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Row( @@ -316,6 +321,23 @@ fun GroupDetailScreen( } GroupColorPalette(selected = form.color, onSelect = { viewModel.updateForm { copy(color = it) } }) + // Parent group (subgroups). Exclude this group and its descendants + // so a group can't become its own ancestor; the server also caps + // nesting depth. + val currentId = if (viewModel.isNewGroup) null else groupId + val eligibleParents = remember(state.allGroups, currentId) { + if (currentId == null) state.allGroups + else { + val descendants = collectDescendants(currentId, state.allGroups) + state.allGroups.filter { it.id != currentId && it.id !in descendants } + } + } + ParentGroupDropdown( + groups = eligibleParents, + selectedId = form.parentId, + onSelect = { viewModel.updateForm { copy(parentId = it) } }, + ) + Button( onClick = { viewModel.save() }, enabled = !state.isSaving && form.name.isNotBlank(), @@ -436,3 +458,75 @@ private fun GroupColorPalette(selected: String, onSelect: (String) -> Unit) { } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ParentGroupDropdown( + groups: List, + selectedId: String?, + onSelect: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedName = groups.firstOrNull { it.id == selectedId }?.name + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedName ?: "None (top-level)", + onValueChange = {}, + readOnly = true, + label = { Text("Parent group") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("None (top-level)") }, + onClick = { onSelect(null); expanded = false }, + ) + groups.forEach { g -> + DropdownMenuItem( + text = { Text(g.name) }, + onClick = { onSelect(g.id); expanded = false }, + ) + } + } + } +} + +/** Ids of every group descended from [rootId] (its children, recursively). */ +private fun collectDescendants( + rootId: String, + all: List, +): Set { + val childrenOf = all.groupBy { it.parentId } + val result = mutableSetOf() + val stack = ArrayDeque() + stack.add(rootId) + while (stack.isNotEmpty()) { + childrenOf[stack.removeLast()]?.forEach { child -> + if (result.add(child.id)) stack.add(child.id) + } + } + return result +} + +/** + * Flatten the group list into parent-before-children order with a depth for + * each, so the list can indent subgroups under their parent. Roots are groups + * with no parent (or a parent that isn't in the set); orphans fall back to + * roots so nothing is dropped. + */ +private fun orderGroupsHierarchically( + groups: List, +): List> { + val byId = groups.associateBy { it.id } + val childrenOf = groups.groupBy { it.parentId } + val out = mutableListOf>() + fun visit(group: systems.lupine.sheaf.data.model.GroupRead, depth: Int) { + out += group to depth + childrenOf[group.id]?.sortedBy { it.name.lowercase() }?.forEach { visit(it, depth + 1) } + } + groups.filter { it.parentId == null || it.parentId !in byId } + .sortedBy { it.name.lowercase() } + .forEach { visit(it, 0) } + return out +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsViewModel.kt index 3184d7a..24c9e6e 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsViewModel.kt @@ -120,12 +120,16 @@ data class GroupFormState( val name: String = "", val description: String = "", val color: String = "#534AB7", + // null = top-level group; otherwise the parent group's id (subgroups). + val parentId: String? = null, ) data class GroupDetailUiState( val group: GroupRead? = null, val members: List = emptyList(), val allMembers: List = emptyList(), + // Every group, for the parent-group picker (subgroups). + val allGroups: List = emptyList(), val isLoading: Boolean = false, val isSaving: Boolean = false, val isDeleting: Boolean = false, @@ -157,6 +161,14 @@ class GroupDetailViewModel @Inject constructor( markdownImages.loadUser(viewModelScope) if (!isNewGroup && groupId != null) load() loadAllMembers() + loadAllGroups() + } + + private fun loadAllGroups() { + viewModelScope.launch { + runCatching { api.listGroups() } + .onSuccess { groups -> _state.update { it.copy(allGroups = groups) } } + } } private fun load() { @@ -179,6 +191,7 @@ class GroupDetailViewModel @Inject constructor( name = group.name, description = group.description ?: "", color = group.color ?: "#534AB7", + parentId = group.parentId, ) }.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.toUserMessage()) } @@ -206,12 +219,14 @@ class GroupDetailViewModel @Inject constructor( name = f.name.trim(), description = f.description.takeIf { it.isNotBlank() }, color = f.color.takeIf { it.isNotBlank() }, + parentId = f.parentId, )) } else { api.updateGroup(groupId!!, GroupUpdate( name = f.name.trim(), description = f.description.takeIf { it.isNotBlank() }, color = f.color.takeIf { it.isNotBlank() }, + parentId = f.parentId, )) } } From 5d7d2cde2500de8fa80862b3cd1d79bfd741a76f Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:16:23 -0400 Subject: [PATCH 09/12] changelog: member archive, subgroups, formatting help --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b148607..6de1923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,19 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). service status, and policy links (when configured), and links to the project source and security contact. +- **Archive members.** Members can be archived: a reversible soft-hide + that keeps them out of the main roster and switcher without deleting + them. Archive from a member's long-press menu; archived members live + in a collapsible section with an Unarchive action. + +- **Subgroups.** Groups can now nest. The group editor has a parent + picker, and the groups list indents subgroups under their parent. + +- **Formatting help.** The markdown editor toolbar (member bios, + journals, group and system descriptions) gains a help button with a + quick formatting reference, including how to make a single line break + versus a new paragraph. + ### Fixed - **Front history editor rejects an end before the start.** Editing a From a640790373b542b5a8cad609b085e68c9d4b79b6 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:25:44 -0400 Subject: [PATCH 10/12] feat(members): archive/unarchive button in the member editor Web puts archive in the member editor, not just a list action. Add an Archive/Unarchive button to the member edit screen (existing members), with the same optimistic-then-step-up auth, so it's discoverable where users manage a member. The list long-press shortcut stays. --- .../lupine/sheaf/ui/members/MembersScreen.kt | 27 +++++++++++ .../sheaf/ui/members/MembersViewModel.kt | 46 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 8d8d128..763faff 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -824,9 +824,36 @@ fun MemberDetailScreen( if (state.isSaving) CircularProgressIndicator(Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp) else Text(if (viewModel.isNewMember) "Add Member" else "Save Changes") } + + // Archive / unarchive (existing members only). A reversible + // soft-hide; see also the long-press shortcut on the member list. + if (!viewModel.isNewMember) { + val archived = state.member?.isArchived == true + OutlinedButton( + onClick = { if (archived) viewModel.unarchiveMember() else viewModel.archiveMember() }, + enabled = !state.isArchiving, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isArchiving) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Archive, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (archived) "Unarchive member" else "Archive member") + } + } + } } } + if (state.archiveNeedsAuth) { + ArchiveAuthDialog( + isArchiving = state.isArchiving, + error = state.archiveError, + onConfirm = { password, totp -> viewModel.archiveMember(password, totp) }, + onDismiss = { viewModel.cancelArchiveAuth() }, + ) + } } // ── Member profile ──────────────────────────────────────────────────────────── diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt index 5798cdd..7abdbec 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt @@ -299,6 +299,11 @@ data class MemberDetailUiState( val deleteSafety: MemberDeleteSafety = MemberDeleteSafety(), val deleteError: String? = null, val deleteQueued: Boolean = false, + val isArchiving: Boolean = false, + // True when the server demanded step-up auth to archive (the system's + // archive safety category is on); drives the prompt on the edit screen. + val archiveNeedsAuth: Boolean = false, + val archiveError: String? = null, /** Definitions for every custom field on the system. Loaded alongside * the member so the form can render type-appropriate editors. Order * follows the user's pick in Settings → Custom Fields. */ @@ -492,6 +497,47 @@ class MemberDetailViewModel @Inject constructor( } } + /** + * Archive this member (reversible soft-hide). Tries without credentials; + * if the system's archive safety category is on the server answers 4xx + * and we surface a step-up prompt and retry. Updates the member in place + * so the button flips to Unarchive. + */ + fun archiveMember(password: String? = null, totpCode: String? = null) { + val id = memberId ?: return + viewModelScope.launch { + _state.update { it.copy(isArchiving = true, archiveError = null) } + runCatching { + api.archiveMember(id, MemberArchiveBody(password?.ifBlank { null }, totpCode?.ifBlank { null })) + } + .onSuccess { m -> _state.update { it.copy(isArchiving = false, archiveNeedsAuth = false, member = m) } } + .onFailure { e -> + if (e is retrofit2.HttpException && e.code() in listOf(400, 403)) { + _state.update { + it.copy( + isArchiving = false, + archiveNeedsAuth = true, + archiveError = if (password != null) "Incorrect password or authenticator code" else null, + ) + } + } else { + _state.update { it.copy(isArchiving = false, archiveNeedsAuth = false, error = e.toUserMessage("Couldn't archive member")) } + } + } + } + } + + fun unarchiveMember() { + val id = memberId ?: return + viewModelScope.launch { + runCatching { api.unarchiveMember(id) } + .onSuccess { m -> _state.update { it.copy(member = m) } } + .onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Couldn't unarchive member")) } } + } + } + + fun cancelArchiveAuth() { _state.update { it.copy(archiveNeedsAuth = false, archiveError = null) } } + fun delete(password: String? = null, totpCode: String? = null) { if (memberId == null) return viewModelScope.launch { From 8bf4655fc4a0e155c0d8b9f7079de2eb028f3801 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:29:54 -0400 Subject: [PATCH 11/12] feat(settings): Archived members screen (Settings > System) Web parity: archived members are now also viewable and restorable from Settings > System > Archived members, matching web's archived-members card, in addition to the members-list section and the editor button. --- CHANGELOG.md | 6 +- .../java/systems/lupine/sheaf/ui/SheafApp.kt | 7 + .../sheaf/ui/members/ArchivedMembersScreen.kt | 128 ++++++++++++++++++ .../ui/settings/SettingsCategoryScreens.kt | 9 ++ 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/ArchivedMembersScreen.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de1923..f46508f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,10 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). - **Archive members.** Members can be archived: a reversible soft-hide that keeps them out of the main roster and switcher without deleting - them. Archive from a member's long-press menu; archived members live - in a collapsible section with an Unarchive action. + them. Archive from the member editor or a member's long-press menu; + archived members appear in a collapsible section on the members list + and in a dedicated Settings > System > Archived members screen, each + with an Unarchive action. - **Subgroups.** Groups can now nest. The group editor has a parent picker, and the groups list indents subgroups under their parent. diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index 675746c..0c72010 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -103,6 +103,7 @@ object Routes { const val SETTINGS_NOTIFICATIONS = "settings/notifications" const val SETTINGS_SERVER = "settings/server" const val SETTINGS_SYSTEM = "settings/sys" + const val ARCHIVED_MEMBERS = "settings/archived-members" const val SETTINGS_DATA = "settings/data" const val SETTINGS_SAFETY = "settings/safety-cat" const val SETTINGS_DANGER = "settings/danger" @@ -477,6 +478,12 @@ fun SheafApp( onNavigateUp = { navController.navigateUp() }, onNavigateToCustomFields = { navController.navigate(Routes.CUSTOM_FIELDS) }, onNavigateToTags = { navController.navigate(Routes.SETTINGS_TAGS) }, + onNavigateToArchivedMembers = { navController.navigate(Routes.ARCHIVED_MEMBERS) }, + ) + } + composable(Routes.ARCHIVED_MEMBERS) { + systems.lupine.sheaf.ui.members.ArchivedMembersScreen( + onNavigateUp = { navController.navigateUp() }, ) } composable(Routes.SETTINGS_TAGS) { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/ArchivedMembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/ArchivedMembersScreen.kt new file mode 100644 index 0000000..862e571 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/ArchivedMembersScreen.kt @@ -0,0 +1,128 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package systems.lupine.sheaf.ui.members + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.model.MemberRead +import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.MemberAvatar +import systems.lupine.sheaf.ui.components.SheafTopAppBar +import systems.lupine.sheaf.util.toUserMessage +import javax.inject.Inject + +data class ArchivedMembersUiState( + val isLoading: Boolean = true, + val archived: List = emptyList(), + val unarchivingId: String? = null, + val error: String? = null, +) + +@HiltViewModel +class ArchivedMembersViewModel @Inject constructor( + private val api: SheafApiService, +) : ViewModel() { + + private val _state = MutableStateFlow(ArchivedMembersUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + viewModelScope.launch { + _state.update { it.copy(isLoading = it.archived.isEmpty(), error = null) } + runCatching { api.listMembers() } + .onSuccess { members -> + _state.update { it.copy(isLoading = false, archived = members.filter { m -> m.isArchived }) } + } + .onFailure { e -> _state.update { it.copy(isLoading = false, error = e.toUserMessage()) } } + } + } + + fun unarchive(id: String) { + viewModelScope.launch { + _state.update { it.copy(unarchivingId = id, error = null) } + runCatching { api.unarchiveMember(id) } + .onSuccess { + _state.update { s -> s.copy(unarchivingId = null, archived = s.archived.filterNot { it.id == id }) } + } + .onFailure { e -> _state.update { it.copy(unarchivingId = null, error = e.toUserMessage("Couldn't unarchive member")) } } + } + } +} + +@Composable +fun ArchivedMembersScreen( + onNavigateUp: () -> Unit, + viewModel: ArchivedMembersViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Archived members") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding), + ) { + Text( + "Archived members are hidden from the roster and from switch and " + + "journal pickers, but stay in front history and existing entries. " + + "Unarchive one to bring it back.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + + if (state.error != null) ErrorBanner(state.error!!, modifier = Modifier.padding(horizontal = 16.dp)) + + when { + state.isLoading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + state.archived.isEmpty() -> Text( + "No archived members.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + else -> state.archived.forEach { member -> + ListItem( + headlineContent = { Text(member.displayNameOrName) }, + supportingContent = member.pronouns?.takeIf { it.isNotBlank() }?.let { { Text(it) } }, + leadingContent = { MemberAvatar(member, size = 40.dp) }, + trailingContent = { + if (state.unarchivingId == member.id) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + TextButton(onClick = { viewModel.unarchive(member.id) }) { Text("Unarchive") } + } + }, + ) + } + } + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt index e1dc90a..d99fab2 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt @@ -42,6 +42,7 @@ import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.HourglassEmpty import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.icons.outlined.LocalOffer import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.LockOpen @@ -516,6 +517,7 @@ fun SystemCategoryScreen( onNavigateUp: () -> Unit, onNavigateToCustomFields: () -> Unit, onNavigateToTags: () -> Unit, + onNavigateToArchivedMembers: () -> Unit, ) { CategoryScaffold(title = "System", onNavigateUp = onNavigateUp) { SettingItem( @@ -531,6 +533,13 @@ fun SystemCategoryScreen( subtitle = "Define additional fields for member profiles", onClick = onNavigateToCustomFields, ) + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + SettingItem( + icon = Icons.Outlined.Archive, + title = "Archived members", + subtitle = "View and restore archived members", + onClick = onNavigateToArchivedMembers, + ) } } From 1a8e5941eb0e1d1741776ef5ef050819ff7b1c16 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:37:52 -0400 Subject: [PATCH 12/12] refactor(members): move Delete to the member editor next to Archive Archive lived on the editor while Delete lived on the read-only profile screen, splitting member management across two screens. Move Delete onto the editor (reusing the detail VM's existing delete path) so Save, Archive, and Delete sit together; the profile screen is now purely for viewing. The list long-press menu keeps both as shortcuts. --- .../lupine/sheaf/ui/members/MembersScreen.kt | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 763faff..dacd789 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -427,6 +427,7 @@ fun MemberDetailScreen( val baseline by viewModel.baselineForm.collectAsState() var showAvatarMenu by remember { mutableStateOf(false) } var showBannerMenu by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } // Holds the URI of the just-picked image while the cropper dialog // is on screen. Null means no crop in progress. The crop dialog // confirms with PNG bytes which then go to uploadAvatarBytes; @@ -842,10 +843,34 @@ fun MemberDetailScreen( Text(if (archived) "Unarchive member" else "Archive member") } } + + OutlinedButton( + onClick = { showDeleteDialog = true }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Delete member") + } } } } + if (showDeleteDialog && state.member != null) { + val target = state.member!! + LaunchedEffect(target.id) { viewModel.loadDeleteSafety() } + MemberDeleteDialog( + memberLabel = target.displayNameOrName, + safety = state.deleteSafety, + isDeleting = state.isDeleting, + errorMessage = state.deleteError, + onConfirm = { password, totpCode -> viewModel.delete(password, totpCode) }, + onDismiss = { showDeleteDialog = false; viewModel.clearDeleteError() }, + ) + LaunchedEffect(state.deleted) { if (state.deleted) onNavigateUp() } + } + if (state.archiveNeedsAuth) { ArchiveAuthDialog( isArchiving = state.isArchiving, @@ -866,7 +891,6 @@ fun MemberProfileScreen( viewModel: MemberProfileViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - var showDeleteDialog by remember { mutableStateOf(false) } // Reload data when returning to this screen (e.g. after editing) val lifecycleOwner = LocalLifecycleOwner.current @@ -1122,37 +1146,13 @@ fun MemberProfileScreen( if (state.error != null) { ErrorBanner(state.error!!) } - - Spacer(Modifier.height(8.dp)) - - TextButton( - onClick = { showDeleteDialog = true }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { - Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Delete Member") - } + // Delete + archive live on the member editor (tap Edit), so + // destructive and management actions sit in one place. } } } } - if (showDeleteDialog && member != null) { - LaunchedEffect(member.id) { viewModel.loadDeleteSafety() } - MemberDeleteDialog( - memberLabel = member.displayNameOrName, - safety = state.deleteSafety, - isDeleting = state.isDeleting, - errorMessage = state.deleteError, - onConfirm = { password, totpCode -> viewModel.delete(password, totpCode) }, - onDismiss = { showDeleteDialog = false; viewModel.clearDeleteError() }, - ) - LaunchedEffect(state.deleted) { - if (state.deleted) showDeleteDialog = false - } - } - if (state.showRevisions) { LaunchedEffect(Unit) { viewModel.loadRevisionSafety() } var openRevision by remember { mutableStateOf(null) }