From 783cb2b835a498286b7d01e13bb0f1489c4cd949 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:50:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(materialize):=20preview/edit/confirm=20win?= =?UTF-8?q?dow=20for=20"Create=20from=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the finalization window that opens once a destination is chosen and shows exactly what will be created, before anything is saved. It lives in `:core:materialize` on top of #11's domain so the five entry points share one window instead of re-implementing the preview and the confirm discipline; the Compose plugin is enabled on the module for it. - The destination is switchable from inside the window (List / Doc / Both / Message). Nothing is discarded on a switch: the edits stay in state and `toRequest()` is the single place that decides which of them a destination can carry, so a title survives List → Doc while columns, which a document has no use for, simply do not reach the request — and are still there on the way back. - For a list: title, optional description, column rename / retype / remove / Add column over the twelve-value `ListColumnType`, public-private, and a live table of the rows and columns. A blank title is refused locally because the server answers `A list title is required`. - For a document: title, file name, public/private, numbered or bulleted list style, inline or sub-item row data, with a rendered markdown preview built from the *source* columns — `docConfig` carries no field list, so previewing renamed list columns would promise something the request does not ask for. - On success both navigation targets are exposed independently, so a `both` conversion offers a link into the new list and into the new document. - The subscriber gate is consumed, not re-implemented: a free account's confirm is refused by the repository before a byte leaves the device and surfaces as `subscriptionRequired` for the host's existing upsell handling. Message → message stays hidden, as the help centre documents (Quote or Push covers it), and the message destination creates nothing: it hands the composer the draft the server built. Tests: 21 new unit tests — column edits reaching the create request, the destination switch preserving what still applies and dropping what cannot, both navigation targets on success, a blank title refused with no request, a free account confirming with a real socket that never sees a byte, and the document renderer's four styles. Plus a Compose UI test for the window. `./gradlew :app:assembleDebug testDebugUnitTest` is green (65 tests in `:core:materialize`, 0 failures). Closes #15 --- core/materialize/build.gradle.kts | 32 +- .../materialize/ui/MaterializeWindowTest.kt | 179 ++++ .../core/materialize/ui/DocumentPreview.kt | 64 ++ .../ui/MaterializeErrorMessages.kt | 29 + .../core/materialize/ui/MaterializeWindow.kt | 812 ++++++++++++++++++ .../materialize/ui/MaterializeWindowState.kt | 272 ++++++ .../ui/MaterializeWindowViewModel.kt | 125 +++ .../materialize/ui/DocumentPreviewTest.kt | 88 ++ .../ui/FakeMaterializeRepository.kt | 30 + .../ui/MaterializeWindowViewModelTest.kt | 407 +++++++++ 10 files changed, 2035 insertions(+), 3 deletions(-) create mode 100644 core/materialize/src/androidTest/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowTest.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeErrorMessages.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt create mode 100644 core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/FakeMaterializeRepository.kt create mode 100644 core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt diff --git a/core/materialize/build.gradle.kts b/core/materialize/build.gradle.kts index 1f57cb8..9c286be 100644 --- a/core/materialize/build.gradle.kts +++ b/core/materialize/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.hilt) @@ -12,8 +13,11 @@ android { defaultConfig { minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { compose = true } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -31,6 +35,8 @@ dependencies { implementation(project(":core:common")) // The shared authed Retrofit and `GET /api/user` (read by MaterializeGate). implementation(project(":core:network")) + // The themed Compose surface the preview/confirm window is drawn on. + implementation(project(":core:designsystem")) implementation(libs.retrofit.core) implementation(libs.okhttp.core) @@ -38,10 +44,22 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + // The preview/edit/confirm window lives here rather than in a feature module: + // five entry points (messages, lists, rows, documents, selections) open the + // same window, so re-implementing it per surface would let them drift apart. + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) - // No Room cache and no Compose: `POST /api/materialize` is a one-shot write - // whose result is authoritative, and the preview/confirm UI is built by the - // feature surfaces that open it. + // No Room cache: `POST /api/materialize` is a one-shot write whose result is + // authoritative, so there is nothing to read back offline. testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) @@ -49,4 +67,12 @@ dependencies { // The repository tests drive a real Retrofit/OkHttp stack against MockWebServer. testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.retrofit.kotlinx.serialization) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/core/materialize/src/androidTest/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowTest.kt b/core/materialize/src/androidTest/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowTest.kt new file mode 100644 index 0000000..c8e8e8a --- /dev/null +++ b/core/materialize/src/androidTest/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowTest.kt @@ -0,0 +1,179 @@ +package com.interlinedlist.android.core.materialize.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.domain.MaterializedDocument +import com.interlinedlist.android.core.materialize.domain.MaterializedList +import com.interlinedlist.android.core.materialize.domain.RowDataStyle +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The window as the user sees it: the destination switcher, the list editor with + * its live table, the document editor with its rendered preview, and the success + * state's links into what was created. + */ +@RunWith(AndroidJUnit4::class) +class MaterializeWindowTest { + + @get:Rule + val composeRule = createComposeRule() + + private val preview = MaterializePreview( + suggestedTitle = "Books to Read", + suggestedDescription = "My reading backlog.", + suggestedFileName = "books-to-read.md", + columns = listOf( + MaterializeColumn("title", "Title", ListColumnType.TEXT, sourceKey = "title"), + MaterializeColumn("author", "Author", ListColumnType.TEXT, sourceKey = "author"), + ), + rows = listOf( + MaterializePreviewRow(mapOf("title" to "The Dream Machine", "author" to "Waldrop")), + ), + totalRowCount = 340, + ) + + private fun state( + target: MaterializeTarget = MaterializeTarget.LIST, + source: MaterializeSource = MaterializeSource.Lists(listOf("lst_1")), + ) = MaterializeWindowUiState.from(MaterializeLaunch(source, target, preview)) + + private fun setWindow( + state: MaterializeWindowUiState, + onSelectTarget: (MaterializeTarget) -> Unit = {}, + onOpenList: (MaterializedList) -> Unit = {}, + onOpenDocument: (MaterializedDocument) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + MaterializeWindowContent( + state = state, + onSelectTarget = onSelectTarget, + onTitleChange = {}, + onDescriptionChange = {}, + onPublicChange = {}, + onAddColumn = {}, + onRemoveColumn = {}, + onColumnNameChange = { _, _ -> }, + onColumnTypeChange = { _, _ -> }, + onFileNameChange = {}, + onListStyleChange = {}, + onRowDataStyleChange = {}, + onConfirm = {}, + onDismiss = {}, + onOpenList = onOpenList, + onOpenDocument = onOpenDocument, + onUseDraft = {}, + ) + } + } + } + + @Test + fun theListEditorShowsItsColumnsAndALiveTable() { + val state = state() + setWindow(state) + + composeRule.onNodeWithTag(MaterializeWindowTestTags.TITLE).assertIsDisplayed() + state.columns.forEach { + composeRule.onNodeWithTag(MaterializeWindowTestTags.column(it.uiId)).assertIsDisplayed() + } + composeRule.onNodeWithTag(MaterializeWindowTestTags.ADD_COLUMN).assertIsDisplayed() + composeRule.onNodeWithTag(MaterializeWindowTestTags.TABLE_PREVIEW).assertIsDisplayed() + // The preview is live data from the source, and says how much is coming. + composeRule.onNodeWithText("The Dream Machine").assertIsDisplayed() + composeRule.onNodeWithText("Showing 1 of 340 rows.").assertIsDisplayed() + } + + @Test + fun theDestinationCanBeSwitchedFromInsideTheWindow() { + var selected: MaterializeTarget? = null + setWindow(state(), onSelectTarget = { selected = it }) + + MaterializeTarget.entries.forEach { + composeRule.onNodeWithTag(MaterializeWindowTestTags.destination(it)).assertIsDisplayed() + } + composeRule.onNodeWithTag( + MaterializeWindowTestTags.destination(MaterializeTarget.BOTH), + ).performClick() + + assertThat(selected).isEqualTo(MaterializeTarget.BOTH) + } + + @Test + fun theDocumentEditorShowsItsStylesAndARenderedPreview() { + setWindow(state(target = MaterializeTarget.DOC)) + + composeRule.onNodeWithTag(MaterializeWindowTestTags.FILE_NAME).assertIsDisplayed() + composeRule.onNodeWithTag( + MaterializeWindowTestTags.listStyle(DocumentListStyle.NUMBERED), + ).assertIsDisplayed() + composeRule.onNodeWithTag( + MaterializeWindowTestTags.rowDataStyle(RowDataStyle.SUB_ITEMS), + ).assertIsDisplayed() + composeRule.onNodeWithTag(MaterializeWindowTestTags.DOCUMENT_PREVIEW).assertIsDisplayed() + // A document has no columns to edit. + composeRule.onNodeWithTag(MaterializeWindowTestTags.ADD_COLUMN).assertDoesNotExist() + } + + @Test + fun aBlankListTitleDisablesCreate() { + setWindow(state().copy(title = " ")) + + composeRule.onNodeWithTag(MaterializeWindowTestTags.CONFIRM).assertIsNotEnabled() + composeRule.onNodeWithText("A list title is required").assertIsDisplayed() + } + + @Test + fun aValidListCanBeCreated() { + setWindow(state()) + + composeRule.onNodeWithTag(MaterializeWindowTestTags.CONFIRM).assertIsEnabled() + } + + @Test + fun bothOffersALinkToTheListAndToTheDocument() { + var openedList: MaterializedList? = null + var openedDocument: MaterializedDocument? = null + setWindow( + state(target = MaterializeTarget.BOTH).copy( + success = MaterializeSuccess( + list = MaterializedList("lst_new", "Books to Read"), + document = MaterializedDocument("doc_new", "Books to Read"), + ), + ), + onOpenList = { openedList = it }, + onOpenDocument = { openedDocument = it }, + ) + + composeRule.onNodeWithTag(MaterializeWindowTestTags.SUCCESS).assertIsDisplayed() + composeRule.onNodeWithTag(MaterializeWindowTestTags.OPEN_LIST).performClick() + composeRule.onNodeWithTag(MaterializeWindowTestTags.OPEN_DOCUMENT).performClick() + + assertThat(openedList?.id).isEqualTo("lst_new") + assertThat(openedDocument?.id).isEqualTo("doc_new") + } + + @Test + fun aMessagesSourceDoesNotOfferTheMessageDestination() { + setWindow(state(source = MaterializeSource.Messages(listOf("msg_1")))) + + composeRule.onNodeWithTag( + MaterializeWindowTestTags.destination(MaterializeTarget.MESSAGE), + ).assertDoesNotExist() + } +} diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt new file mode 100644 index 0000000..3ba0b7f --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreview.kt @@ -0,0 +1,64 @@ +package com.interlinedlist.android.core.materialize.ui + +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.RowDataStyle + +/** + * Renders the markdown a document destination would produce, so the window can + * show it before anything is written. + * + * It renders the **source** columns from [preview], not the edited list columns: + * a document is derived server-side from the source, and `docConfig` carries no + * field list, so showing renamed list columns here would promise something the + * request does not ask for. + * + * The result is a preview, not the document: the server re-derives the real one + * from its own copy of the source. + */ +internal fun renderDocumentPreview( + title: String, + preview: MaterializePreview, + listStyle: DocumentListStyle, + rowDataStyle: RowDataStyle, +): String = buildString { + if (title.isNotBlank()) appendLine("# ${title.trim()}").appendLine() + preview.suggestedDescription?.takeIf { it.isNotBlank() }?.let { + appendLine(it.trim()).appendLine() + } + + val headlineKey = preview.columns.firstOrNull()?.propertyKey + preview.rows.forEachIndexed { index, row -> + val marker = when (listStyle) { + DocumentListStyle.NUMBERED -> "${index + 1}. " + DocumentListStyle.BULLETED -> "- " + } + val headline = headlineKey?.let { row.values[it] }?.takeIf { it.isNotBlank() } ?: UNTITLED_ROW + val details = preview.columns.drop(1).mapNotNull { column -> + row.values[column.propertyKey] + ?.takeIf { it.isNotBlank() } + ?.let { column.propertyName to it } + } + + when (rowDataStyle) { + RowDataStyle.INLINE -> { + append(marker).append(headline) + if (details.isNotEmpty()) { + append(" — ").append(details.joinToString(", ") { "${it.first}: ${it.second}" }) + } + appendLine() + } + + RowDataStyle.SUB_ITEMS -> { + appendLine("$marker$headline") + details.forEach { appendLine("$SUB_ITEM_INDENT- ${it.first}: ${it.second}") } + } + } + } + + if (preview.hiddenRowCount > 0) { + appendLine().append("…and ${preview.hiddenRowCount} more") + } +}.trimEnd() + +private const val UNTITLED_ROW = "(untitled)" +private const val SUB_ITEM_INDENT = " " diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeErrorMessages.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeErrorMessages.kt new file mode 100644 index 0000000..7f17d03 --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeErrorMessages.kt @@ -0,0 +1,29 @@ +package com.interlinedlist.android.core.materialize.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** + * Maps a normalised [AppError] to what the window shows. + * + * `bad_request` / `validation_failed` arrive as [AppError.Unknown] carrying the + * server's own words ("A list title is required", "Field 'year' has invalid + * type"), which are far more useful than anything generic, so they are shown + * verbatim. + */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Nothing was created." + is AppError.Unauthorized -> message ?: "Please sign in again." + is AppError.Forbidden -> message ?: "Your account cannot create this right now." + is AppError.NotFound -> + message ?: "Some of what you selected is no longer available." + + is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." + is AppError.SubscriptionRequired -> + message ?: "Creating lists and documents requires an active subscription." + + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Nothing was created." +} + +/** True when the error is the subscriber-only gate, so the UI can show an upsell. */ +val AppError.isSubscriptionGate: Boolean get() = this is AppError.SubscriptionRequired diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt new file mode 100644 index 0000000..538041a --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindow.kt @@ -0,0 +1,812 @@ +package com.interlinedlist.android.core.materialize.ui + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.domain.MaterializedDocument +import com.interlinedlist.android.core.materialize.domain.MaterializedList +import com.interlinedlist.android.core.materialize.domain.MessageDraft +import com.interlinedlist.android.core.materialize.domain.RowDataStyle + +/** Stable test tags for the "Create from…" window. */ +object MaterializeWindowTestTags { + const val WINDOW = "materializeWindow" + const val TITLE = "materializeTitle" + const val DESCRIPTION = "materializeDescription" + const val VISIBILITY = "materializeVisibility" + const val ADD_COLUMN = "materializeAddColumn" + const val TABLE_PREVIEW = "materializeTablePreview" + const val DOCUMENT_PREVIEW = "materializeDocumentPreview" + const val FILE_NAME = "materializeFileName" + const val CONFIRM = "materializeConfirm" + const val CANCEL = "materializeCancel" + const val PROGRESS = "materializeProgress" + const val ERROR = "materializeError" + const val SUBSCRIPTION = "materializeSubscription" + const val SUCCESS = "materializeSuccess" + const val OPEN_LIST = "materializeOpenList" + const val OPEN_DOCUMENT = "materializeOpenDocument" + const val OPEN_COMPOSER = "materializeOpenComposer" + fun destination(target: MaterializeTarget) = "materializeDestination_${target.apiValue}" + fun column(uiId: Long) = "materializeColumn_$uiId" + fun columnName(uiId: Long) = "materializeColumnName_$uiId" + fun columnType(uiId: Long) = "materializeColumnType_$uiId" + fun removeColumn(uiId: Long) = "materializeColumnRemove_$uiId" + fun listStyle(style: DocumentListStyle) = "materializeListStyle_${style.apiValue}" + fun rowDataStyle(style: RowDataStyle) = "materializeRowDataStyle_${style.apiValue}" +} + +/** + * The "Create from…" finalization window: shows exactly what will be created, + * lets the user change it, and writes nothing until they confirm. + * + * Entry-point agnostic by construction — it takes a [MaterializeLaunch] (an + * id-only source, the destination the menu picked, and a preview of the source) + * and reports back through [onOpenList] / [onOpenDocument] / [onUseDraft], so + * messages, lists, rows, documents and document selections all share one window + * instead of each re-implementing the preview and the confirm discipline. + */ +@Composable +fun MaterializeWindow( + launch: MaterializeLaunch, + onDismiss: () -> Unit, + onOpenList: (MaterializedList) -> Unit, + onOpenDocument: (MaterializedDocument) -> Unit, + onUseDraft: (MessageDraft) -> Unit = {}, + modifier: Modifier = Modifier, + viewModel: MaterializeWindowViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(launch) { viewModel.start(launch) } + + state?.let { current -> + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = modifier + .fillMaxWidth(WINDOW_WIDTH_FRACTION) + .fillMaxHeight(WINDOW_HEIGHT_FRACTION) + .testTag(MaterializeWindowTestTags.WINDOW), + shape = MaterialTheme.shapes.large, + tonalElevation = 6.dp, + ) { + MaterializeWindowContent( + state = current, + onSelectTarget = viewModel::selectTarget, + onTitleChange = viewModel::updateTitle, + onDescriptionChange = viewModel::updateDescription, + onPublicChange = viewModel::setPublic, + onAddColumn = viewModel::addColumn, + onRemoveColumn = viewModel::removeColumn, + onColumnNameChange = viewModel::renameColumn, + onColumnTypeChange = viewModel::changeColumnType, + onFileNameChange = viewModel::updateFileName, + onListStyleChange = viewModel::selectListStyle, + onRowDataStyleChange = viewModel::selectRowDataStyle, + onConfirm = viewModel::confirm, + onDismiss = onDismiss, + onOpenList = onOpenList, + onOpenDocument = onOpenDocument, + onUseDraft = onUseDraft, + ) + } + } + } +} + +/** The window's stateless body: destination switcher, editor, preview, confirm. */ +@Composable +fun MaterializeWindowContent( + state: MaterializeWindowUiState, + onSelectTarget: (MaterializeTarget) -> Unit, + onTitleChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onPublicChange: (Boolean) -> Unit, + onAddColumn: () -> Unit, + onRemoveColumn: (Long) -> Unit, + onColumnNameChange: (Long, String) -> Unit, + onColumnTypeChange: (Long, ListColumnType) -> Unit, + onFileNameChange: (String) -> Unit, + onListStyleChange: (DocumentListStyle) -> Unit, + onRowDataStyleChange: (RowDataStyle) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + onOpenList: (MaterializedList) -> Unit, + onOpenDocument: (MaterializedDocument) -> Unit, + onUseDraft: (MessageDraft) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + WindowHeader(onDismiss = onDismiss) + + if (state.success == null) { + DestinationSwitcher( + targets = state.availableTargets, + selected = state.target, + onSelect = onSelectTarget, + ) + } + HorizontalDivider() + + Box(Modifier.weight(1f)) { + if (state.success != null) { + SuccessPane( + success = state.success, + onOpenList = onOpenList, + onOpenDocument = onOpenDocument, + onUseDraft = onUseDraft, + ) + } else { + EditorPane( + state = state, + onTitleChange = onTitleChange, + onDescriptionChange = onDescriptionChange, + onPublicChange = onPublicChange, + onAddColumn = onAddColumn, + onRemoveColumn = onRemoveColumn, + onColumnNameChange = onColumnNameChange, + onColumnTypeChange = onColumnTypeChange, + onFileNameChange = onFileNameChange, + onListStyleChange = onListStyleChange, + onRowDataStyleChange = onRowDataStyleChange, + ) + } + } + + HorizontalDivider() + WindowFooter(state = state, onConfirm = onConfirm, onDismiss = onDismiss) + } +} + +@Composable +private fun WindowHeader(onDismiss: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Create from…", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } +} + +/** The destination can be changed here, without losing what has been edited. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DestinationSwitcher( + targets: List, + selected: MaterializeTarget, + onSelect: (MaterializeTarget) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + targets.forEach { target -> + FilterChip( + selected = target == selected, + onClick = { onSelect(target) }, + label = { Text(target.label) }, + modifier = Modifier.testTag(MaterializeWindowTestTags.destination(target)), + ) + } + } +} + +@Composable +private fun EditorPane( + state: MaterializeWindowUiState, + onTitleChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onPublicChange: (Boolean) -> Unit, + onAddColumn: () -> Unit, + onRemoveColumn: (Long) -> Unit, + onColumnNameChange: (Long, String) -> Unit, + onColumnTypeChange: (Long, ListColumnType) -> Unit, + onFileNameChange: (String) -> Unit, + onListStyleChange: (DocumentListStyle) -> Unit, + onRowDataStyleChange: (RowDataStyle) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Banners(state) + + if (state.createsDraft) { + DraftNotice() + return@Column + } + + OutlinedTextField( + value = state.title, + onValueChange = onTitleChange, + label = { Text(if (state.createsList) "List title" else "Document title") }, + isError = state.titleError != null, + supportingText = state.titleError?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(MaterializeWindowTestTags.TITLE), + ) + + VisibilityRow(state = state, onPublicChange = onPublicChange) + + if (state.createsList) { + OutlinedTextField( + value = state.description, + onValueChange = onDescriptionChange, + label = { Text("Description (optional)") }, + modifier = Modifier + .fillMaxWidth() + .testTag(MaterializeWindowTestTags.DESCRIPTION), + ) + ColumnsEditor( + columns = state.columns, + onAddColumn = onAddColumn, + onRemoveColumn = onRemoveColumn, + onColumnNameChange = onColumnNameChange, + onColumnTypeChange = onColumnTypeChange, + ) + TablePreview(state) + } + + if (state.createsDocument) { + if (state.createsList) HorizontalDivider() + OutlinedTextField( + value = state.fileName, + onValueChange = onFileNameChange, + label = { Text("File name (optional)") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(MaterializeWindowTestTags.FILE_NAME), + ) + if (state.showsRowLayoutOptions) { + DocumentStyleOptions( + state = state, + onListStyleChange = onListStyleChange, + onRowDataStyleChange = onRowDataStyleChange, + ) + } + DocumentPreviewPane(state) + } + } +} + +@Composable +private fun Banners(state: MaterializeWindowUiState) { + if (state.subscriptionRequired) { + Card(modifier = Modifier.testTag(MaterializeWindowTestTags.SUBSCRIPTION)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Subscribers only", style = MaterialTheme.typography.titleMedium) + Text( + text = state.errorMessage + ?: "Creating lists and documents requires an active subscription.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } else if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .testTag(MaterializeWindowTestTags.ERROR), + ) + } +} + +/** The one destination that creates nothing: it hands the composer a draft. */ +@Composable +private fun DraftNotice() { + Card { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Opens in the composer", style = MaterialTheme.typography.titleMedium) + Text( + text = "Nothing is posted here. InterlinedList builds the draft from your " + + "selection and the composer opens with it, so you can edit it, add " + + "cross-post targets or a schedule, and post it yourself.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun VisibilityRow(state: MaterializeWindowUiState, onPublicChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(MaterializeWindowTestTags.VISIBILITY), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Make it public", style = MaterialTheme.typography.bodyLarge) + Text( + text = if (state.isPublic) { + "Anyone with the link can see it." + } else { + "Only you can see it." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = state.isPublic, onCheckedChange = onPublicChange) + } +} + +@Composable +private fun ColumnsEditor( + columns: List, + onAddColumn: () -> Unit, + onRemoveColumn: (Long) -> Unit, + onColumnNameChange: (Long, String) -> Unit, + onColumnTypeChange: (Long, ListColumnType) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Columns", style = MaterialTheme.typography.titleMedium) + columns.forEach { column -> + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(MaterializeWindowTestTags.column(column.uiId)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = column.name, + onValueChange = { onColumnNameChange(column.uiId, it) }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier + .weight(1f) + .testTag(MaterializeWindowTestTags.columnName(column.uiId)), + ) + ColumnTypePicker( + column = column, + onColumnTypeChange = { onColumnTypeChange(column.uiId, it) }, + ) + IconButton( + onClick = { onRemoveColumn(column.uiId) }, + modifier = Modifier.testTag(MaterializeWindowTestTags.removeColumn(column.uiId)), + ) { Icon(Icons.Default.Delete, contentDescription = "Remove column") } + } + } + TextButton( + onClick = onAddColumn, + modifier = Modifier.testTag(MaterializeWindowTestTags.ADD_COLUMN), + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Add column") + } + } +} + +@Composable +private fun ColumnTypePicker( + column: EditableColumn, + onColumnTypeChange: (ListColumnType) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.testTag(MaterializeWindowTestTags.columnType(column.uiId)), + ) { + Text(column.type.label) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + // The twelve types the schema accepts; anything else is a bad_request. + ListColumnType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(type.label) }, + onClick = { + expanded = false + onColumnTypeChange(type) + }, + ) + } + } + } +} + +/** The live table: the rows and columns the list will be created with. */ +@Composable +private fun TablePreview(state: MaterializeWindowUiState) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag(MaterializeWindowTestTags.TABLE_PREVIEW), + ) { + Text("Preview", style = MaterialTheme.typography.titleMedium) + when { + state.columns.isEmpty() -> PreviewHint("This list will start empty. Add a column to shape it.") + + else -> Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + Row { + state.columns.forEach { column -> + PreviewCell( + text = column.name.ifBlank { "Untitled" }, + style = MaterialTheme.typography.labelLarge, + ) + } + } + HorizontalDivider() + state.preview.rows.forEach { row -> + Row { + state.columns.forEach { column -> + PreviewCell( + // A user-added column has no source data: it is created empty. + text = column.propertyKey?.let { row.values[it] }.orEmpty(), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } + PreviewHint(state.preview.rowSummary) + } +} + +/** The document as it would read, in the chosen styles. */ +@Composable +private fun DocumentPreviewPane(state: MaterializeWindowUiState) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag(MaterializeWindowTestTags.DOCUMENT_PREVIEW), + ) { + Text("Preview", style = MaterialTheme.typography.titleMedium) + val rendered = state.documentPreview + Card(Modifier.fillMaxWidth()) { + Text( + text = rendered.ifBlank { "InterlinedList will build this document from your selection." }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DocumentStyleOptions( + state: MaterializeWindowUiState, + onListStyleChange: (DocumentListStyle) -> Unit, + onRowDataStyleChange: (RowDataStyle) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("List style", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DocumentListStyle.entries.forEach { style -> + FilterChip( + selected = state.listStyle == style, + onClick = { onListStyleChange(style) }, + label = { Text(style.label) }, + modifier = Modifier.testTag(MaterializeWindowTestTags.listStyle(style)), + ) + } + } + Text("Row data", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RowDataStyle.entries.forEach { style -> + FilterChip( + selected = state.rowDataStyle == style, + onClick = { onRowDataStyleChange(style) }, + label = { Text(style.label) }, + modifier = Modifier.testTag(MaterializeWindowTestTags.rowDataStyle(style)), + ) + } + } + } +} + +/** + * What was created, with a link into each of it. A `both` conversion made two + * objects, so it offers both links. + */ +@Composable +private fun SuccessPane( + success: MaterializeSuccess, + onOpenList: (MaterializedList) -> Unit, + onOpenDocument: (MaterializedDocument) -> Unit, + onUseDraft: (MessageDraft) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .testTag(MaterializeWindowTestTags.SUCCESS), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val draft = success.draft + if (draft != null) { + Text("Your draft is ready", style = MaterialTheme.typography.titleMedium) + Text( + text = draft.content, + style = MaterialTheme.typography.bodyMedium, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + Button( + onClick = { onUseDraft(draft) }, + modifier = Modifier.testTag(MaterializeWindowTestTags.OPEN_COMPOSER), + ) { Text("Open in composer") } + return@Column + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Created", style = MaterialTheme.typography.titleMedium) + } + + success.list?.let { list -> + Button( + onClick = { onOpenList(list) }, + modifier = Modifier.testTag(MaterializeWindowTestTags.OPEN_LIST), + ) { Text("Open list") } + Text( + text = list.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + success.document?.let { document -> + Button( + onClick = { onOpenDocument(document) }, + modifier = Modifier.testTag(MaterializeWindowTestTags.OPEN_DOCUMENT), + ) { Text("Open document") } + Text( + text = document.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun WindowFooter( + state: MaterializeWindowUiState, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(MaterializeWindowTestTags.CANCEL), + ) { Text(if (state.success == null) "Cancel" else "Done") } + + if (state.success == null) { + Spacer(Modifier.width(8.dp)) + Button( + onClick = onConfirm, + enabled = state.canConfirm, + modifier = Modifier.testTag(MaterializeWindowTestTags.CONFIRM), + ) { + if (state.isSubmitting) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + .testTag(MaterializeWindowTestTags.PROGRESS), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text(if (state.createsDraft) "Build draft" else "Create") + } + } + } +} + +@Composable +private fun PreviewCell(text: String, style: TextStyle) { + Text( + text = text, + style = style, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(CELL_WIDTH) + .padding(8.dp), + ) +} + +@Composable +private fun PreviewHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** How many rows the new list gets, and how much of it is on screen. */ +private val MaterializePreview.rowSummary: String + get() = when { + totalRowCount == 0 -> "No rows to preview." + hiddenRowCount > 0 -> "Showing ${rows.size} of $totalRowCount rows." + totalRowCount == 1 -> "1 row." + else -> "$totalRowCount rows." + } + +private val MaterializeTarget.label: String + get() = when (this) { + MaterializeTarget.LIST -> "To List" + MaterializeTarget.DOC -> "To Doc" + MaterializeTarget.BOTH -> "To List & Doc" + MaterializeTarget.MESSAGE -> "To Message" + } + +private val DocumentListStyle.label: String + get() = when (this) { + DocumentListStyle.NUMBERED -> "Numbered" + DocumentListStyle.BULLETED -> "Bulleted" + } + +private val RowDataStyle.label: String + get() = when (this) { + RowDataStyle.INLINE -> "Inline" + RowDataStyle.SUB_ITEMS -> "Sub-items" + } + +/** Readable names for the twelve column types the list schema accepts. */ +internal val ListColumnType.label: String + get() = when (this) { + ListColumnType.TEXT -> "Text" + ListColumnType.TEXTAREA -> "Long text" + ListColumnType.NUMBER -> "Number" + ListColumnType.BOOLEAN -> "Yes / no" + ListColumnType.DATE -> "Date" + ListColumnType.DATETIME -> "Date & time" + ListColumnType.EMAIL -> "Email" + ListColumnType.URL -> "URL" + ListColumnType.TEL -> "Phone" + ListColumnType.SELECT -> "Select" + ListColumnType.MULTISELECT -> "Multi-select" + ListColumnType.PRIORITY -> "Priority" + } + +private const val WINDOW_WIDTH_FRACTION = 0.96f +private const val WINDOW_HEIGHT_FRACTION = 0.92f +private val CELL_WIDTH = 140.dp + +@Preview(showBackground = true, heightDp = 900) +@Composable +private fun MaterializeWindowPreview() { + InterlinedListTheme { + MaterializeWindowContent( + state = previewState(), + onSelectTarget = {}, + onTitleChange = {}, + onDescriptionChange = {}, + onPublicChange = {}, + onAddColumn = {}, + onRemoveColumn = {}, + onColumnNameChange = { _, _ -> }, + onColumnTypeChange = { _, _ -> }, + onFileNameChange = {}, + onListStyleChange = {}, + onRowDataStyleChange = {}, + onConfirm = {}, + onDismiss = {}, + onOpenList = {}, + onOpenDocument = {}, + onUseDraft = {}, + ) + } +} + +private fun previewState() = MaterializeWindowUiState.from( + MaterializeLaunch( + source = MaterializeSource.Lists(listOf("lst_1")), + initialTarget = MaterializeTarget.BOTH, + preview = MaterializePreview( + suggestedTitle = "Books to Read", + suggestedDescription = "My reading backlog.", + suggestedFileName = "books-to-read.md", + columns = listOf( + MaterializeColumn("title", "Title", ListColumnType.TEXT, sourceKey = "title"), + MaterializeColumn("author", "Author", ListColumnType.TEXT, sourceKey = "author"), + ), + rows = listOf( + MaterializePreviewRow(mapOf("title" to "The Dream Machine", "author" to "Waldrop")), + MaterializePreviewRow(mapOf("title" to "Thinking in Systems", "author" to "Meadows")), + ), + totalRowCount = 340, + ), + ), +) diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt new file mode 100644 index 0000000..685c6bd --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowState.kt @@ -0,0 +1,272 @@ +package com.interlinedlist.android.core.materialize.ui + +import com.interlinedlist.android.core.materialize.domain.DocConfig +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.ListConfig +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.domain.MaterializedDocument +import com.interlinedlist.android.core.materialize.domain.MaterializedList +import com.interlinedlist.android.core.materialize.domain.MessageDraft +import com.interlinedlist.android.core.materialize.domain.RowDataStyle + +/** + * Everything the "Create from…" window needs to open, handed to it by whichever + * surface started the flow. + * + * The window is deliberately entry-point agnostic: messages, lists, rows and + * documents all open the same window, so it takes an id-only [source], the + * destination the user picked from the menu, and a [preview] of what that + * source looks like. It never reaches back into a feature module for data. + */ +data class MaterializeLaunch( + val source: MaterializeSource, + val initialTarget: MaterializeTarget = MaterializeTarget.LIST, + val preview: MaterializePreview = MaterializePreview(), +) + +/** + * What the entry point already has on screen, so the window can show the rows + * and columns that will be created **before** anything is saved. + * + * This is display material and a starting point for the editor, never a payload: + * `POST /api/materialize` takes ids only and re-derives every cell server-side + * (see `MaterializeSource`). Only [columns] leave the device, and only as a + * schema — `propertyName`/`propertyType`/`sourceKey`, never a value. + * + * [totalRowCount] is the true size of the source even when [rows] holds only the + * handful the window shows, which is what lets the preview say "340 items in + * total" the way the web window does. + */ +data class MaterializePreview( + val suggestedTitle: String = "", + val suggestedDescription: String? = null, + /** Suggested file name for a document destination; maps to `relativePath`. */ + val suggestedFileName: String? = null, + val columns: List = emptyList(), + val rows: List = emptyList(), + val totalRowCount: Int = rows.size, + /** The account's `defaultPubliclyVisible`, so the toggle opens where the user expects. */ + val defaultIsPublic: Boolean = false, +) { + /** How many rows exist beyond the ones being shown. */ + val hiddenRowCount: Int get() = (totalRowCount - rows.size).coerceAtLeast(0) +} + +/** One previewed row: display values keyed by the column's `propertyKey`. */ +data class MaterializePreviewRow(val values: Map) + +/** + * One column as the window edits it. + * + * [uiId] keys the row while the user types, so a rename or a removal stays on + * the right column; it is never sent. [propertyKey] is null for a column the + * user added — the key is derived from the name when the request is built — + * and [sourceKey] is null for the same reason: a user-added column has no + * source attribute to derive values from, so it is created empty. + */ +data class EditableColumn( + val uiId: Long, + val name: String, + val type: ListColumnType = ListColumnType.TEXT, + val sourceKey: String? = null, + val propertyKey: String? = null, +) { + /** A column with no name has nothing to create; it is dropped on confirm. */ + val isUsable: Boolean get() = name.isNotBlank() +} + +/** + * What a confirmed conversion produced, as the window renders it. + * + * Both navigation targets are exposed independently, because the `both` + * destination creates two objects and the success state has to offer a link to + * each. [draft] is the `message` destination's result — nothing was created. + */ +data class MaterializeSuccess( + val list: MaterializedList? = null, + val document: MaterializedDocument? = null, + val draft: MessageDraft? = null, +) { + val canOpenList: Boolean get() = list != null + val canOpenDocument: Boolean get() = document != null + val hasDraft: Boolean get() = draft != null +} + +/** Folds an outcome into the navigation targets the success state offers. */ +internal fun MaterializeOutcome.toSuccess(): MaterializeSuccess = when (this) { + is MaterializeOutcome.ListCreated -> MaterializeSuccess(list = list) + is MaterializeOutcome.DocumentCreated -> MaterializeSuccess(document = document) + is MaterializeOutcome.ListAndDocumentCreated -> + MaterializeSuccess(list = list, document = document) + + is MaterializeOutcome.DraftReady -> MaterializeSuccess(draft = draft) +} + +/** + * The window's state: one editing surface shared by all four destinations. + * + * Switching destination only changes [target] — nothing is thrown away, so an + * edit that still applies is still there when the user comes back, while an edit + * the new destination cannot use (a document has no columns) simply does not + * reach the request. [toRequest] is the single place that decides which edits a + * destination carries. + * + * [title] and [isPublic] are shared rather than duplicated per destination: the + * user is naming and publishing one thing, and the `both` destination gives the + * list and the document the same title, exactly as the published example does. + */ +data class MaterializeWindowUiState( + val source: MaterializeSource, + val target: MaterializeTarget, + val preview: MaterializePreview, + val title: String, + val description: String, + val isPublic: Boolean, + val columns: List, + val fileName: String, + val listStyle: DocumentListStyle = DocumentListStyle.BULLETED, + val rowDataStyle: RowDataStyle = RowDataStyle.INLINE, + val isSubmitting: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val success: MaterializeSuccess? = null, +) { + + /** + * The destinations offered in this window. Message → message is the one + * combination the product hides (Quote or Push covers it), so a messages + * source does not offer it. + */ + val availableTargets: List + get() = MaterializeTarget.entries.filter { + it != MaterializeTarget.MESSAGE || source !is MaterializeSource.Messages + } + + val createsList: Boolean + get() = target == MaterializeTarget.LIST || target == MaterializeTarget.BOTH + + val createsDocument: Boolean + get() = target == MaterializeTarget.DOC || target == MaterializeTarget.BOTH + + val createsDraft: Boolean get() = target == MaterializeTarget.MESSAGE + + /** + * The server validates the list title right after the target and answers + * `A list title is required`, so the window refuses a blank one first. A + * document title really is optional — omitted, the server derives one. + */ + val titleError: String? + get() = if (createsList && title.isBlank()) LIST_TITLE_REQUIRED else null + + val canConfirm: Boolean get() = !isSubmitting && success == null && titleError == null + + /** The row layout controls only bite when there is row data to lay out. */ + val showsRowLayoutOptions: Boolean get() = createsDocument && preview.rows.isNotEmpty() + + /** The rendered document preview, as the chosen styles would lay it out. */ + val documentPreview: String + get() = renderDocumentPreview(title, preview, listStyle, rowDataStyle) + + companion object { + const val LIST_TITLE_REQUIRED: String = "A list title is required" + + /** Opens the window on the entry point's suggestions. */ + fun from(launch: MaterializeLaunch): MaterializeWindowUiState = MaterializeWindowUiState( + source = launch.source, + target = launch.initialTarget, + preview = launch.preview, + title = launch.preview.suggestedTitle, + description = launch.preview.suggestedDescription.orEmpty(), + isPublic = launch.preview.defaultIsPublic, + columns = launch.preview.columns.mapIndexed { index, column -> + EditableColumn( + uiId = index.toLong(), + name = column.propertyName, + type = column.propertyType, + sourceKey = column.sourceKey, + propertyKey = column.propertyKey, + ) + }, + fileName = launch.preview.suggestedFileName.orEmpty(), + ) + } +} + +/** + * Projects the window onto the request its destination accepts. + * + * This is where "preserves the edits that still apply, discards the ones that + * cannot" happens: the list edits go out only when a list is being created, the + * document edits only when a document is, and the draft destination carries + * neither because the server builds the body from the source itself. + */ +internal fun MaterializeWindowUiState.toRequest(): MaterializeRequest { + require(titleError == null) { LIST_TITLE_REQUIRED_MESSAGE } + return when (target) { + MaterializeTarget.LIST -> MaterializeRequest.ToList(source, toListConfig()) + MaterializeTarget.DOC -> MaterializeRequest.ToDocument(source, toDocConfig()) + MaterializeTarget.BOTH -> + MaterializeRequest.ToListAndDocument(source, toListConfig(), toDocConfig()) + + MaterializeTarget.MESSAGE -> MaterializeRequest.ToMessageDraft(source) + } +} + +private const val LIST_TITLE_REQUIRED_MESSAGE = + "A list destination cannot be confirmed without a title" + +private fun MaterializeWindowUiState.toListConfig(): ListConfig = ListConfig( + title = title.trim(), + description = description.trim().takeIf { it.isNotBlank() }, + isPublic = isPublic, + // No columns to speak of leaves the server's own derivation alone. + fields = columns.filter { it.isUsable }.takeIf { it.isNotEmpty() }?.toColumns(), +) + +private fun MaterializeWindowUiState.toDocConfig(): DocConfig = DocConfig( + title = title.trim().takeIf { it.isNotBlank() }, + relativePath = fileName.trim().takeIf { it.isNotBlank() }, + isPublic = isPublic, + listStyle = listStyle, + rowDataStyle = rowDataStyle, +) + +/** + * Turns the edited rows into column definitions, deriving a `propertyKey` for + * every user-added column and keeping those keys distinct — two columns sharing + * a key would silently collapse into one. + */ +private fun List.toColumns(): List { + val used = mutableSetOf() + return map { column -> + val key = distinctKey(column.propertyKey ?: column.name.toPropertyKey(), used) + used += key + MaterializeColumn( + propertyKey = key, + propertyName = column.name.trim(), + propertyType = column.type, + sourceKey = column.sourceKey, + ) + } +} + +private fun distinctKey(candidate: String, used: Set): String { + if (candidate !in used) return candidate + var suffix = 2 + while ("${candidate}_$suffix" in used) suffix++ + return "${candidate}_$suffix" +} + +/** `Reading notes` → `reading_notes`; the schema keys the API accepts. */ +private fun String.toPropertyKey(): String = trim() + .lowercase() + .map { if (it.isLetterOrDigit()) it else '_' } + .joinToString("") + .trim('_') + .replace(Regex("_+"), "_") + .ifBlank { "column" } diff --git a/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt new file mode 100644 index 0000000..fc87f3c --- /dev/null +++ b/core/materialize/src/main/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModel.kt @@ -0,0 +1,125 @@ +package com.interlinedlist.android.core.materialize.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.materialize.data.MaterializeRepository +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.domain.RowDataStyle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Drives the "Create from…" preview / edit / confirm window. + * + * The window edits a local draft and writes nothing until [confirm]; the + * subscriber gate is not re-implemented here — `MaterializeRepository` refuses a + * free account's creation before the request is built, and that refusal arrives + * as `AppError.SubscriptionRequired`, which this exposes as + * [MaterializeWindowUiState.subscriptionRequired] for the host's existing + * subscription handling. + * + * The state is null until [start] supplies the source: the window is opened from + * five different entry points with objects that are not nav arguments, so the + * host hands them over once the composable is on screen. + */ +@HiltViewModel +class MaterializeWindowViewModel @Inject constructor( + private val repository: MaterializeRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(null) + val uiState: StateFlow = _uiState.asStateFlow() + + private var activeLaunch: MaterializeLaunch? = null + private var nextUiId = 0L + + /** Opens the window. Re-opening with the same launch keeps the edits. */ + fun start(launch: MaterializeLaunch) { + if (activeLaunch == launch) return + activeLaunch = launch + val state = MaterializeWindowUiState.from(launch) + nextUiId = state.columns.size.toLong() + _uiState.value = state + } + + /** + * Switches destination from inside the window. Nothing is discarded here: + * the edits stay in state and [MaterializeWindowUiState.toRequest] decides + * which of them the new destination can carry. + */ + fun selectTarget(target: MaterializeTarget) = mutate { + if (it.target == target) it + else it.copy(target = target, errorMessage = null, subscriptionRequired = false) + } + + fun updateTitle(title: String) = mutate { it.copy(title = title, errorMessage = null) } + + fun updateDescription(description: String) = mutate { it.copy(description = description) } + + fun setPublic(isPublic: Boolean) = mutate { it.copy(isPublic = isPublic) } + + fun updateFileName(fileName: String) = mutate { it.copy(fileName = fileName) } + + fun selectListStyle(style: DocumentListStyle) = mutate { it.copy(listStyle = style) } + + fun selectRowDataStyle(style: RowDataStyle) = mutate { it.copy(rowDataStyle = style) } + + /** Adds an empty column — no source attribute, so it is created blank. */ + fun addColumn() = mutate { + it.copy(columns = it.columns + EditableColumn(uiId = nextUiId++, name = "")) + } + + fun removeColumn(uiId: Long) = mutate { + it.copy(columns = it.columns.filterNot { column -> column.uiId == uiId }) + } + + fun renameColumn(uiId: Long, name: String) = mutateColumn(uiId) { it.copy(name = name) } + + fun changeColumnType(uiId: Long, type: ListColumnType) = + mutateColumn(uiId) { it.copy(type = type) } + + fun dismissError() = mutate { it.copy(errorMessage = null, subscriptionRequired = false) } + + /** Creates what the window is previewing. The first write in the whole flow. */ + fun confirm() { + val state = _uiState.value ?: return + if (!state.canConfirm) { + // Refused locally: the server would only answer the same thing. + state.titleError?.let { error -> mutate { it.copy(errorMessage = error) } } + return + } + + val request = state.toRequest() + mutate { it.copy(isSubmitting = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.materialize(request)) { + is ApiResult.Success -> mutate { + it.copy(isSubmitting = false, success = result.data.toSuccess()) + } + + is ApiResult.Failure -> mutate { + it.copy( + isSubmitting = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + private fun mutate(transform: (MaterializeWindowUiState) -> MaterializeWindowUiState) = + _uiState.update { state -> state?.let(transform) } + + private fun mutateColumn(uiId: Long, transform: (EditableColumn) -> EditableColumn) = mutate { + it.copy(columns = it.columns.map { column -> if (column.uiId == uiId) transform(column) else column }) + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt new file mode 100644 index 0000000..fa008af --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/DocumentPreviewTest.kt @@ -0,0 +1,88 @@ +package com.interlinedlist.android.core.materialize.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.RowDataStyle +import org.junit.Test + +/** + * The rendered document preview. It is built from the **source** columns, not + * from the edited list columns: a document is derived by the server from the + * source, so previewing renamed list columns here would promise something the + * request does not carry. + */ +class DocumentPreviewTest { + + private val preview = MaterializePreview( + suggestedTitle = "Books to Read", + suggestedDescription = "My reading backlog.", + columns = listOf( + MaterializeColumn("title", "Title", ListColumnType.TEXT, sourceKey = "title"), + MaterializeColumn("author", "Author", ListColumnType.TEXT, sourceKey = "author"), + MaterializeColumn("year", "Year", ListColumnType.NUMBER, sourceKey = "year"), + ), + rows = listOf( + MaterializePreviewRow( + mapOf("title" to "The Dream Machine", "author" to "Waldrop", "year" to "2001"), + ), + MaterializePreviewRow(mapOf("title" to "Thinking in Systems", "author" to "Meadows")), + ), + totalRowCount = 340, + ) + + @Test + fun `bulleted inline puts each row on one line with its fields appended`() { + val rendered = renderDocumentPreview( + title = "Books to Read", + preview = preview, + listStyle = DocumentListStyle.BULLETED, + rowDataStyle = RowDataStyle.INLINE, + ) + + assertThat(rendered).contains("# Books to Read") + assertThat(rendered).contains("- The Dream Machine — Author: Waldrop, Year: 2001") + // A missing value is left out rather than rendered as an empty field. + assertThat(rendered).contains("- Thinking in Systems — Author: Meadows") + } + + @Test + fun `numbered sub-items indents each field under its row`() { + val rendered = renderDocumentPreview( + title = "Books to Read", + preview = preview, + listStyle = DocumentListStyle.NUMBERED, + rowDataStyle = RowDataStyle.SUB_ITEMS, + ) + + assertThat(rendered).contains("1. The Dream Machine") + assertThat(rendered).contains(" - Author: Waldrop") + assertThat(rendered).contains(" - Year: 2001") + assertThat(rendered).contains("2. Thinking in Systems") + } + + @Test + fun `a truncated preview says how many more rows are coming`() { + val rendered = renderDocumentPreview( + title = "Books to Read", + preview = preview, + listStyle = DocumentListStyle.BULLETED, + rowDataStyle = RowDataStyle.INLINE, + ) + + assertThat(rendered).contains("…and 338 more") + } + + @Test + fun `a source with no rows still previews its heading`() { + val rendered = renderDocumentPreview( + title = "Launch notes", + preview = MaterializePreview(), + listStyle = DocumentListStyle.BULLETED, + rowDataStyle = RowDataStyle.INLINE, + ) + + assertThat(rendered).isEqualTo("# Launch notes") + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/FakeMaterializeRepository.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/FakeMaterializeRepository.kt new file mode 100644 index 0000000..c25d1d5 --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/FakeMaterializeRepository.kt @@ -0,0 +1,30 @@ +package com.interlinedlist.android.core.materialize.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.materialize.data.MaterializeRepository +import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest + +/** + * Records what the window asked to be created. + * + * The window's job ends at the request it hands the repository; that request's + * serialisation is covered by `MaterializeRequestBodyTest` against a real + * Retrofit/MockWebServer stack, so these tests assert at the seam instead of + * racing a socket. + */ +internal class FakeMaterializeRepository( + var result: ApiResult = ApiResult.Success( + MaterializeOutcome.ListCreated( + com.interlinedlist.android.core.materialize.domain.MaterializedList("lst_new", "Books to Read"), + ), + ), +) : MaterializeRepository { + + val requests = mutableListOf() + + override suspend fun materialize(request: MaterializeRequest): ApiResult { + requests += request + return result + } +} diff --git a/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt new file mode 100644 index 0000000..ec42bd9 --- /dev/null +++ b/core/materialize/src/test/kotlin/com/interlinedlist/android/core/materialize/ui/MaterializeWindowViewModelTest.kt @@ -0,0 +1,407 @@ +package com.interlinedlist.android.core.materialize.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.materialize.data.gateFor +import com.interlinedlist.android.core.materialize.data.repositoryFor +import com.interlinedlist.android.core.materialize.domain.DocumentListStyle +import com.interlinedlist.android.core.materialize.domain.ListColumnType +import com.interlinedlist.android.core.materialize.domain.MaterializeColumn +import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome +import com.interlinedlist.android.core.materialize.domain.MaterializeRequest +import com.interlinedlist.android.core.materialize.domain.MaterializeSource +import com.interlinedlist.android.core.materialize.domain.MaterializeTarget +import com.interlinedlist.android.core.materialize.domain.MaterializedDocument +import com.interlinedlist.android.core.materialize.domain.MaterializedList +import com.interlinedlist.android.core.materialize.domain.MessageDraft +import com.interlinedlist.android.core.materialize.domain.RowDataStyle +import com.interlinedlist.android.core.model.CustomerStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The preview / edit / confirm window. + * + * The window's contract is the request it hands the repository, so these tests + * assert on that request; how it serialises is covered end-to-end against + * MockWebServer by `MaterializeRequestBodyTest`. The one test that must prove + * *nothing at all* went out uses the real repository over a real socket. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MaterializeWindowViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val repository = FakeMaterializeRepository() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private val source = MaterializeSource.Lists(listOf("lst_source")) + + private fun preview() = MaterializePreview( + suggestedTitle = "Books to Read", + suggestedDescription = "My reading backlog.", + suggestedFileName = "books-to-read.md", + columns = listOf( + MaterializeColumn("title", "Title", ListColumnType.TEXT, sourceKey = "title"), + MaterializeColumn("author", "Author", ListColumnType.TEXT, sourceKey = "author"), + MaterializeColumn("year", "Year", ListColumnType.NUMBER, sourceKey = "year"), + ), + rows = listOf( + MaterializePreviewRow( + mapOf("title" to "The Dream Machine", "author" to "Waldrop", "year" to "2001"), + ), + ), + totalRowCount = 340, + ) + + private fun viewModel( + target: MaterializeTarget = MaterializeTarget.LIST, + source: MaterializeSource = this.source, + ): MaterializeWindowViewModel = MaterializeWindowViewModel(repository).also { + it.start(MaterializeLaunch(source, target, preview())) + } + + private val MaterializeWindowViewModel.state: MaterializeWindowUiState + get() = requireNotNull(uiState.value) { "the window was never started" } + + private fun sentRequest(): MaterializeRequest { + assertThat(repository.requests).hasSize(1) + return repository.requests.single() + } + + // ------------------------------------------------------------- the editor + + @Test + fun `the window opens on the seeded defaults without creating anything`() = runTest(dispatcher) { + val vm = viewModel() + + assertThat(vm.state.title).isEqualTo("Books to Read") + assertThat(vm.state.description).isEqualTo("My reading backlog.") + assertThat(vm.state.fileName).isEqualTo("books-to-read.md") + assertThat(vm.state.columns.map { it.name }) + .containsExactly("Title", "Author", "Year").inOrder() + // Preview first: nothing is written until the user confirms. + assertThat(repository.requests).isEmpty() + } + + @Test + fun `column edits survive into the create request`() = runTest(dispatcher) { + val vm = viewModel() + val seeded = vm.state.columns + + vm.renameColumn(seeded[0].uiId, "Book") + vm.changeColumnType(seeded[2].uiId, ListColumnType.DATE) + vm.removeColumn(seeded[1].uiId) + vm.addColumn() + val added = vm.state.columns.last() + vm.renameColumn(added.uiId, "Notes") + vm.changeColumnType(added.uiId, ListColumnType.TEXTAREA) + + vm.confirm() + advanceUntilIdle() + + val fields = (sentRequest() as MaterializeRequest.ToList).listConfig.fields.orEmpty() + assertThat(fields.map { it.propertyName }).containsExactly("Book", "Year", "Notes").inOrder() + assertThat(fields.map { it.propertyType }).containsExactly( + ListColumnType.TEXT, + ListColumnType.DATE, + ListColumnType.TEXTAREA, + ).inOrder() + // A rename keeps the source mapping; the removed column is gone. + assertThat(fields[0].sourceKey).isEqualTo("title") + assertThat(fields[1].sourceKey).isEqualTo("year") + // A user-added column has no source attribute: it is created empty. + assertThat(fields[2].sourceKey).isNull() + assertThat(fields[2].propertyKey).isEqualTo("notes") + } + + @Test + fun `title description and visibility edits reach the list config`() = runTest(dispatcher) { + val vm = viewModel() + + vm.updateTitle("Reading backlog") + vm.updateDescription("Everything still queued up.") + vm.setPublic(true) + vm.confirm() + advanceUntilIdle() + + val config = (sentRequest() as MaterializeRequest.ToList).listConfig + assertThat(config.title).isEqualTo("Reading backlog") + assertThat(config.description).isEqualTo("Everything still queued up.") + assertThat(config.isPublic).isTrue() + } + + @Test + fun `an unnamed column is dropped rather than created blank`() = runTest(dispatcher) { + val vm = viewModel() + + vm.addColumn() // added and never named + vm.confirm() + advanceUntilIdle() + + val fields = (sentRequest() as MaterializeRequest.ToList).listConfig.fields.orEmpty() + assertThat(fields.map { it.propertyName }) + .containsExactly("Title", "Author", "Year").inOrder() + } + + // -------------------------------------------------- switching destinations + + @Test + fun `switching to both keeps every list edit and adds the document half`() = + runTest(dispatcher) { + val vm = viewModel() + vm.updateTitle("Reading backlog") + vm.renameColumn(vm.state.columns[0].uiId, "Book") + + vm.selectTarget(MaterializeTarget.BOTH) + + // The edits are still on screen after the switch. + assertThat(vm.state.title).isEqualTo("Reading backlog") + assertThat(vm.state.columns.map { it.name }).contains("Book") + + vm.confirm() + advanceUntilIdle() + + val request = sentRequest() as MaterializeRequest.ToListAndDocument + assertThat(request.listConfig.fields!!.first().propertyName).isEqualTo("Book") + // The document half is configured too, and shares the title. + assertThat(request.docConfig?.title).isEqualTo("Reading backlog") + } + + @Test + fun `switching to a document keeps the title but discards what a document cannot use`() = + runTest(dispatcher) { + val vm = viewModel() + vm.updateTitle("Reading backlog") + vm.updateDescription("Everything still queued up.") + vm.renameColumn(vm.state.columns[0].uiId, "Book") + + vm.selectTarget(MaterializeTarget.DOC) + vm.updateFileName("reading-backlog.md") + vm.selectListStyle(DocumentListStyle.NUMBERED) + vm.selectRowDataStyle(RowDataStyle.SUB_ITEMS) + vm.confirm() + advanceUntilIdle() + + // A document has no columns and no list description: neither is sent. + val request = sentRequest() + assertThat(request).isInstanceOf(MaterializeRequest.ToDocument::class.java) + val config = (request as MaterializeRequest.ToDocument).docConfig + // The title still applies, so it carried over unchanged. + assertThat(config?.title).isEqualTo("Reading backlog") + assertThat(config?.relativePath).isEqualTo("reading-backlog.md") + assertThat(config?.listStyle).isEqualTo(DocumentListStyle.NUMBERED) + assertThat(config?.rowDataStyle).isEqualTo(RowDataStyle.SUB_ITEMS) + } + + @Test + fun `switching back to a list restores the column edits the document could not use`() = + runTest(dispatcher) { + val vm = viewModel() + vm.renameColumn(vm.state.columns[0].uiId, "Book") + vm.removeColumn(vm.state.columns[1].uiId) + + vm.selectTarget(MaterializeTarget.DOC) + vm.selectTarget(MaterializeTarget.LIST) + + assertThat(vm.state.columns.map { it.name }).containsExactly("Book", "Year").inOrder() + + vm.confirm() + advanceUntilIdle() + + val fields = (sentRequest() as MaterializeRequest.ToList).listConfig.fields.orEmpty() + assertThat(fields.map { it.propertyName }).containsExactly("Book", "Year").inOrder() + } + + @Test + fun `switching to a message sends neither config because a draft uses neither`() = + runTest(dispatcher) { + repository.result = ApiResult.Success( + MaterializeOutcome.DraftReady( + MessageDraft("Books to Read", listOf("Books to Read"), isThread = false, charLimit = 300), + ), + ) + val vm = viewModel() + vm.renameColumn(vm.state.columns[0].uiId, "Book") + + vm.selectTarget(MaterializeTarget.MESSAGE) + vm.confirm() + advanceUntilIdle() + + val request = sentRequest() + assertThat(request).isInstanceOf(MaterializeRequest.ToMessageDraft::class.java) + assertThat(request.target).isEqualTo(MaterializeTarget.MESSAGE) + // The draft is handed back for the composer; nothing was created. + val success = requireNotNull(vm.state.success) + assertThat(success.draft?.content).isEqualTo("Books to Read") + assertThat(success.canOpenList).isFalse() + assertThat(success.canOpenDocument).isFalse() + } + + @Test + fun `a messages source does not offer the message destination`() = runTest(dispatcher) { + val vm = viewModel(source = MaterializeSource.Messages(listOf("msg_1"))) + + // "A message can't be converted to a message; use Quote or Push." + assertThat(vm.state.availableTargets).containsExactly( + MaterializeTarget.LIST, + MaterializeTarget.DOC, + MaterializeTarget.BOTH, + ).inOrder() + } + + // ---------------------------------------------------------------- success + + @Test + fun `both exposes the list and the document navigation targets on success`() = + runTest(dispatcher) { + repository.result = ApiResult.Success( + MaterializeOutcome.ListAndDocumentCreated( + list = MaterializedList("lst_new", "Books to Read"), + document = MaterializedDocument("doc_new", "Books to Read"), + ), + ) + val vm = viewModel(target = MaterializeTarget.BOTH) + + vm.confirm() + advanceUntilIdle() + + val success = requireNotNull(vm.state.success) + assertThat(success.list?.id).isEqualTo("lst_new") + assertThat(success.document?.id).isEqualTo("doc_new") + assertThat(success.canOpenList).isTrue() + assertThat(success.canOpenDocument).isTrue() + assertThat(vm.state.isSubmitting).isFalse() + } + + @Test + fun `a list only conversion exposes only the list target`() = runTest(dispatcher) { + val vm = viewModel() + + vm.confirm() + advanceUntilIdle() + + val success = requireNotNull(vm.state.success) + assertThat(success.list?.id).isEqualTo("lst_new") + assertThat(success.canOpenDocument).isFalse() + } + + @Test + fun `a confirmed window does not create a second copy`() = runTest(dispatcher) { + val vm = viewModel() + + vm.confirm() + advanceUntilIdle() + vm.confirm() + advanceUntilIdle() + + assertThat(repository.requests).hasSize(1) + } + + // ------------------------------------------------------------- refusals + + @Test + fun `a blank list title is refused before any request is made`() = runTest(dispatcher) { + val vm = viewModel() + + vm.updateTitle(" ") + + assertThat(vm.state.canConfirm).isFalse() + assertThat(vm.state.titleError).isEqualTo("A list title is required") + + vm.confirm() + advanceUntilIdle() + + assertThat(repository.requests).isEmpty() + assertThat(vm.state.success).isNull() + assertThat(vm.state.errorMessage).isEqualTo("A list title is required") + } + + @Test + fun `a blank title is fine for a document because the server derives one`() = + runTest(dispatcher) { + repository.result = ApiResult.Success( + MaterializeOutcome.DocumentCreated(MaterializedDocument("doc_new", "Books to Read")), + ) + val vm = viewModel(target = MaterializeTarget.DOC) + + vm.updateTitle("") + + assertThat(vm.state.titleError).isNull() + assertThat(vm.state.canConfirm).isTrue() + + vm.confirm() + advanceUntilIdle() + + assertThat((sentRequest() as MaterializeRequest.ToDocument).docConfig?.title).isNull() + } + + @Test + fun `a subscription refusal routes to the subscription handling`() = runTest(dispatcher) { + repository.result = ApiResult.Failure(AppError.SubscriptionRequired(null)) + val vm = viewModel() + + vm.confirm() + advanceUntilIdle() + + assertThat(vm.state.subscriptionRequired).isTrue() + assertThat(vm.state.success).isNull() + assertThat(vm.state.isSubmitting).isFalse() + assertThat(vm.state.errorMessage) + .isEqualTo("Creating lists and documents requires an active subscription.") + } + + @Test + fun `a server failure surfaces its own words and leaves the edits intact`() = + runTest(dispatcher) { + repository.result = ApiResult.Failure(AppError.Unknown("Field 'year' has invalid type")) + val vm = viewModel() + vm.renameColumn(vm.state.columns[0].uiId, "Book") + + vm.confirm() + advanceUntilIdle() + + assertThat(vm.state.errorMessage).isEqualTo("Field 'year' has invalid type") + assertThat(vm.state.success).isNull() + assertThat(vm.state.columns.map { it.name }).contains("Book") + } + + /** + * The one claim a fake cannot make: a free account's confirm must not put a + * single byte on the wire. This runs the real repository and the real gate + * over a real socket and asserts the socket never saw anything. + */ + @Test + fun `a free account confirming issues no write at all`() = runTest(dispatcher) { + val server = MockWebServer().also { it.start() } + try { + val gate = gateFor(server, dispatcher).also { it.record(CustomerStatus.FREE) } + val vm = MaterializeWindowViewModel(repositoryFor(server, dispatcher, gate)).also { + it.start(MaterializeLaunch(source, MaterializeTarget.LIST, preview())) + } + + vm.confirm() + advanceUntilIdle() + + assertThat(server.requestCount).isEqualTo(0) + assertThat(vm.state.subscriptionRequired).isTrue() + assertThat(vm.state.success).isNull() + } finally { + server.shutdown() + } + } +}