- 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:
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user