DRY pass #2, batch 6, F18: one failure message, one factory shape, one editor sitting (#5372)

- ui/Failure.kt: Throwable.shownAs(fallback) and FALLBACK_ERROR. The
  core's own message, else a fallback, which Share, Tags and Sync each
  defined privately and Board and Update wrote inline.
- The five view-model factories use lifecycle's viewModelFactory { initializer }
  instead of an unchecked-cast object each.
- BoardViewModel.beginSitting: the editingSession bump the four editor
  openings each spelled out.

The fallback line stays an English constant, as it was: view models hold no
Context to read strings.xml. Formatted with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:49:44 -04:00
co-authored by Claude Opus 5.5
parent 3dd47fa178
commit ef759e7d8b
6 changed files with 57 additions and 63 deletions
@@ -7,6 +7,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note import com.fabledsword.inkwell.core.Note
@@ -178,7 +180,7 @@ class BoardViewModel(
// Broad by intent: the board must render something for any // Broad by intent: the board must render something for any
// failure, and the core reports problems as one error type // failure, and the core reports problems as one error type
// carrying a message meant to be shown. // carrying a message meant to be shown.
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR) state.copy(loading = false, error = e.shownAs())
} }
} }
} }
@@ -268,7 +270,7 @@ class BoardViewModel(
val hits = withContext(Dispatchers.IO) { load(destination, filters, text) } val hits = withContext(Dispatchers.IO) { load(destination, filters, text) }
state.copy(notes = hits, loading = false, error = null) state.copy(notes = hits, loading = false, error = null)
} catch (e: Exception) { } catch (e: Exception) {
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR) state.copy(loading = false, error = e.shownAs())
} }
} }
} }
@@ -285,11 +287,20 @@ class BoardViewModel(
fun openNoteById(id: String) { fun openNoteById(id: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } } runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) } .onSuccess { beginSitting(it) }
} }
} }
fun openNote(note: Note) { fun openNote(note: Note) {
beginSitting(note)
}
/**
* A new sitting in the editor, on [note]: bumps [BoardState.editingSession] so
* the editor's field is re-keyed onto what it now shows. Saving within a
* sitting never comes through here.
*/
private fun beginSitting(note: Note? = state.editing) {
state = state.copy(editing = note, editingSession = state.editingSession + 1) state = state.copy(editing = note, editingSession = state.editingSession + 1)
} }
@@ -307,7 +318,7 @@ class BoardViewModel(
*/ */
fun compose() { fun compose() {
draftDismissed = false draftDismissed = false
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1) beginSitting(blankDraft())
} }
/** /**
@@ -335,7 +346,7 @@ class BoardViewModel(
// Draft` deliberately does not bump this — it is written for the autosave // Draft` deliberately does not bump this — it is written for the autosave
// case, where re-keying mid-typing would be the bug. // case, where re-keying mid-typing would be the bug.
draftDismissed = false draftDismissed = false
state = state.copy(editingSession = state.editingSession + 1) beginSitting()
// A shared photo arrives with no words, and the note it makes is still a // A shared photo arrives with no words, and the note it makes is still a
// note: the picture is its content. // note: the picture is its content.
createFromDraft(content, allowEmpty = files.isNotEmpty()) { created -> createFromDraft(content, allowEmpty = files.isNotEmpty()) { created ->
@@ -423,7 +434,7 @@ class BoardViewModel(
error = null, error = null,
) )
} catch (e: Exception) { } catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR) state.copy(saving = false, error = e.shownAs())
} }
if (!draftDismissed) state.editing?.let(then) if (!draftDismissed) state.editing?.let(then)
} }
@@ -607,7 +618,7 @@ class BoardViewModel(
// Broad by intent, as elsewhere: the core reports every failure // Broad by intent, as elsewhere: the core reports every failure
// as one error type carrying a message meant to be shown, and a // as one error type carrying a message meant to be shown, and a
// half-applied edit must still leave a usable screen. // half-applied edit must still leave a usable screen.
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR) state.copy(saving = false, error = e.shownAs())
} }
} }
} }
@@ -645,7 +656,6 @@ class BoardViewModel(
} }
companion object { companion object {
private const val FALLBACK_ERROR = "Something went wrong."
private const val SEARCH_DEBOUNCE_MS = 180L private const val SEARCH_DEBOUNCE_MS = 180L
// The core's board vocabulary. "archived", not "archive" — it matches on // The core's board vocabulary. "archived", not "archive" — it matches on
@@ -659,11 +669,7 @@ class BoardViewModel(
readFile: (Uri) -> PickedFile, readFile: (Uri) -> PickedFile,
onRemindersChanged: () -> Unit, onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory =
object : ViewModelProvider.Factory { viewModelFactory { initializer { BoardViewModel(core, onRemindersChanged, readFile) } }
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
BoardViewModel(core, onRemindersChanged, readFile) as T
}
} }
} }
@@ -0,0 +1,14 @@
package com.fabledsword.inkwell.ui
/** What a failure says when the core gave it no message of its own. */
internal const val FALLBACK_ERROR = "Something went wrong."
/**
* The message to show for a failure.
*
* The core writes its errors for people to read ("notes.example.com responded, but
* not with Inkwell's configuration"), so they are shown as they are rather than
* replaced with a generic line that would throw away the only useful part.
* [fallback] is for the failure that carries no message at all.
*/
internal fun Throwable.shownAs(fallback: String = FALLBACK_ERROR): String = message ?: fallback
@@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.CoreException import com.fabledsword.inkwell.core.CoreException
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Member import com.fabledsword.inkwell.core.Member
@@ -99,7 +101,7 @@ class ShareViewModel(
) )
} }
} catch (e: CoreException) { } catch (e: CoreException) {
state.copy(loading = false, loadError = e.describeShareFailure()) state.copy(loading = false, loadError = e.shownAs())
} }
} }
} }
@@ -126,7 +128,7 @@ class ShareViewModel(
onStoreChanged() onStoreChanged()
state.copy(busy = false, shares = shares) state.copy(busy = false, shares = shares)
} catch (e: CoreException) { } catch (e: CoreException) {
state.copy(busy = false, error = e.describeShareFailure()) state.copy(busy = false, error = e.shownAs())
} }
} }
} }
@@ -135,13 +137,6 @@ class ShareViewModel(
fun factory( fun factory(
core: Inkwell, core: Inkwell,
onStoreChanged: () -> Unit, onStoreChanged: () -> Unit,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory = viewModelFactory { initializer { ShareViewModel(core, onStoreChanged) } }
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = ShareViewModel(core, onStoreChanged) as T
}
} }
} }
/** The core's message is written to be shown ("The server doesn't have this note yet…"). */
private fun Throwable.describeShareFailure(): String = message ?: "Something went wrong."
@@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.Compatibility import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.ProbeResult import com.fabledsword.inkwell.core.ProbeResult
@@ -143,7 +145,7 @@ class SyncViewModel(
// A store that won't answer is a real fault, but the screen // A store that won't answer is a real fault, but the screen
// still has to render — showing the unlinked state is honest, // still has to render — showing the unlinked state is honest,
// since without a readable link there is effectively none. // since without a readable link there is effectively none.
state.copy(loading = false, status = null, syncError = e.describe()) state.copy(loading = false, status = null, syncError = e.shownAs())
} }
} }
} }
@@ -163,7 +165,7 @@ class SyncViewModel(
try { try {
state.copy(probe = core.probe(url), probing = false) state.copy(probe = core.probe(url), probing = false)
} catch (e: Exception) { } catch (e: Exception) {
state.copy(probing = false, probeError = e.describe()) state.copy(probing = false, probeError = e.shownAs())
} }
} }
} }
@@ -217,7 +219,7 @@ class SyncViewModel(
status = withContext(Dispatchers.IO) { core.syncStatus() }, status = withContext(Dispatchers.IO) { core.syncStatus() },
) )
} catch (e: Exception) { } catch (e: Exception) {
state.copy(linking = false, linkError = e.describe()) state.copy(linking = false, linkError = e.shownAs())
} }
if (state.linked) syncNow() if (state.linked) syncNow()
} }
@@ -259,7 +261,7 @@ class SyncViewModel(
} catch (e: Exception) { } catch (e: Exception) {
state.copy( state.copy(
syncing = false, syncing = false,
syncError = if (announce) e.describe() else state.syncError, syncError = if (announce) e.shownAs() else state.syncError,
) )
} }
// Only when something actually arrived: a no-op sync must not make the // Only when something actually arrived: a no-op sync must not make the
@@ -293,7 +295,7 @@ class SyncViewModel(
pending = false, pending = false,
) )
} catch (e: Exception) { } catch (e: Exception) {
state.copy(syncing = false, syncError = e.describe()) state.copy(syncing = false, syncError = e.shownAs())
} }
} }
} }
@@ -318,11 +320,7 @@ class SyncViewModel(
fun factory( fun factory(
core: Inkwell, core: Inkwell,
onStoreChanged: () -> Unit, onStoreChanged: () -> Unit,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory = viewModelFactory { initializer { SyncViewModel(core, onStoreChanged) } }
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SyncViewModel(core, onStoreChanged) as T
}
} }
} }
@@ -339,12 +337,3 @@ private fun SyncOutcome.changedTheStore(): Boolean =
pull.labelsApplied > 0uL || pull.labelsApplied > 0uL ||
pull.labelsDeleted > 0uL || pull.labelsDeleted > 0uL ||
pull.blobsDownloaded > 0uL pull.blobsDownloaded > 0uL
/**
* The message to show for a failure.
*
* The core writes these for people to read — "notes.example.com responded, but not
* with Inkwell's configuration" — so they are shown as-is rather than
* replaced with a generic string that would throw away the only useful part.
*/
private fun Exception.describe(): String = message ?: "Something went wrong."
@@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.core.Inkwell import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label import com.fabledsword.inkwell.core.Label
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -86,7 +88,7 @@ class TagsViewModel(
// the only thing on the display, and an empty list here // the only thing on the display, and an empty list here
// would read as "you have no tags" rather than "I couldn't // would read as "you have no tags" rather than "I couldn't
// look". // look".
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) }, onFailure = { state.copy(loading = false, error = it.shownAs()) },
) )
} }
} }
@@ -154,7 +156,7 @@ class TagsViewModel(
state.copy( state.copy(
tags = tags, tags = tags,
busy = false, busy = false,
error = failure?.describeTagFailure(), error = failure?.shownAs(),
) )
// Even a FAILED write can have changed the store — a merge that threw // Even a FAILED write can have changed the store — a merge that threw
// partway still moved rows — so the board is told either way. // partway still moved rows — so the board is told either way.
@@ -166,16 +168,6 @@ class TagsViewModel(
fun factory( fun factory(
core: Inkwell, core: Inkwell,
onStoreChanged: () -> Unit, onStoreChanged: () -> Unit,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory = viewModelFactory { initializer { TagsViewModel(core, onStoreChanged) } }
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
}
} }
} }
/**
* The core reports problems as one error type carrying a message meant to be
* shown, so the message is used when there is one.
*/
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
@@ -7,6 +7,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.fabledsword.inkwell.AppUpdate import com.fabledsword.inkwell.AppUpdate
import com.fabledsword.inkwell.UpdateOutcome import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.inkwell.core.ClientUpdate import com.fabledsword.inkwell.core.ClientUpdate
@@ -143,7 +145,7 @@ class UpdateViewModel(
// Broad by intent, as everywhere the core is called: it reports // Broad by intent, as everywhere the core is called: it reports
// every failure as one error type carrying a message written to // every failure as one error type carrying a message written to
// be read, and a failed check must not take the screen down. // be read, and a failed check must not take the screen down.
state.copy(checking = false, error = e.message ?: FALLBACK) state.copy(checking = false, error = e.shownAs(FALLBACK))
} }
// Fetched before anything is said, so the banner is a one-tap install // Fetched before anything is said, so the banner is a one-tap install
// rather than the start of a wait. Off wifi this simply does not happen // rather than the start of a wait. Off wifi this simply does not happen
@@ -162,7 +164,7 @@ class UpdateViewModel(
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath) core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
state.copy(downloading = false, ready = true) state.copy(downloading = false, ready = true)
} catch (e: Exception) { } catch (e: Exception) {
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD) state.copy(downloading = false, error = e.shownAs(FALLBACK_DOWNLOAD))
} }
} }
@@ -190,7 +192,7 @@ class UpdateViewModel(
// Off the main thread: this streams ~55 MiB into the session. // Off the main thread: this streams ~55 MiB into the session.
withContext(Dispatchers.IO) { AppUpdate.install(context, target) } withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
} catch (e: Exception) { } catch (e: Exception) {
e.message ?: FALLBACK e.shownAs(FALLBACK)
} }
// `working` stays TRUE on success: the install is still in flight, and // `working` stays TRUE on success: the install is still in flight, and
// on a silent update this process is about to be replaced. Clearing it // on a silent update this process is about to be replaced. Clearing it
@@ -220,11 +222,7 @@ class UpdateViewModel(
core: Inkwell, core: Inkwell,
context: Context, context: Context,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory =
object : ViewModelProvider.Factory { viewModelFactory { initializer { UpdateViewModel(core, context.applicationContext) } }
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
UpdateViewModel(core, context.applicationContext) as T
}
} }
} }