diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt index 3a431a8..9092e3a 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider 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.Label import com.fabledsword.inkwell.core.Note @@ -178,7 +180,7 @@ class BoardViewModel( // Broad by intent: the board must render something for any // failure, and the core reports problems as one error type // 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) } state.copy(notes = hits, loading = false, error = null) } 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) { viewModelScope.launch { runCatching { withContext(Dispatchers.IO) { core.getNote(id) } } - .onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) } + .onSuccess { beginSitting(it) } } } 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) } @@ -307,7 +318,7 @@ class BoardViewModel( */ fun compose() { 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 // case, where re-keying mid-typing would be the bug. 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 // note: the picture is its content. createFromDraft(content, allowEmpty = files.isNotEmpty()) { created -> @@ -423,7 +434,7 @@ class BoardViewModel( error = null, ) } 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) } @@ -607,7 +618,7 @@ class BoardViewModel( // Broad by intent, as elsewhere: the core reports every failure // as one error type carrying a message meant to be shown, and a // 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 { - private const val FALLBACK_ERROR = "Something went wrong." private const val SEARCH_DEBOUNCE_MS = 180L // The core's board vocabulary. "archived", not "archive" — it matches on @@ -659,11 +669,7 @@ class BoardViewModel( readFile: (Uri) -> PickedFile, onRemindersChanged: () -> Unit, ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - BoardViewModel(core, onRemindersChanged, readFile) as T - } + viewModelFactory { initializer { BoardViewModel(core, onRemindersChanged, readFile) } } } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/Failure.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/Failure.kt new file mode 100644 index 0000000..a0e84bc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/Failure.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt index d1da29a..aeb355b 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider 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.Inkwell import com.fabledsword.inkwell.core.Member @@ -99,7 +101,7 @@ class ShareViewModel( ) } } catch (e: CoreException) { - state.copy(loading = false, loadError = e.describeShareFailure()) + state.copy(loading = false, loadError = e.shownAs()) } } } @@ -126,7 +128,7 @@ class ShareViewModel( onStoreChanged() state.copy(busy = false, shares = shares) } 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( core: Inkwell, onStoreChanged: () -> Unit, - ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = ShareViewModel(core, onStoreChanged) as T - } + ): ViewModelProvider.Factory = viewModelFactory { initializer { ShareViewModel(core, onStoreChanged) } } } } - -/** 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." diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncViewModel.kt index 7e7a212..c25469c 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncViewModel.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider 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.Inkwell 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 // still has to render — showing the unlinked state is honest, // 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 { state.copy(probe = core.probe(url), probing = false) } 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() }, ) } catch (e: Exception) { - state.copy(linking = false, linkError = e.describe()) + state.copy(linking = false, linkError = e.shownAs()) } if (state.linked) syncNow() } @@ -259,7 +261,7 @@ class SyncViewModel( } catch (e: Exception) { state.copy( 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 @@ -293,7 +295,7 @@ class SyncViewModel( pending = false, ) } 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( core: Inkwell, onStoreChanged: () -> Unit, - ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = SyncViewModel(core, onStoreChanged) as T - } + ): ViewModelProvider.Factory = viewModelFactory { initializer { SyncViewModel(core, onStoreChanged) } } } } @@ -339,12 +337,3 @@ private fun SyncOutcome.changedTheStore(): Boolean = pull.labelsApplied > 0uL || pull.labelsDeleted > 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." diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsViewModel.kt index a85df54..feec88c 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsViewModel.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider 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.Label import kotlinx.coroutines.Dispatchers @@ -86,7 +88,7 @@ class TagsViewModel( // the only thing on the display, and an empty list here // would read as "you have no tags" rather than "I couldn't // 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( tags = tags, busy = false, - error = failure?.describeTagFailure(), + error = failure?.shownAs(), ) // Even a FAILED write can have changed the store — a merge that threw // partway still moved rows — so the board is told either way. @@ -166,16 +168,6 @@ class TagsViewModel( fun factory( core: Inkwell, onStoreChanged: () -> Unit, - ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = TagsViewModel(core, onStoreChanged) as T - } + ): ViewModelProvider.Factory = viewModelFactory { initializer { TagsViewModel(core, onStoreChanged) } } } } - -/** - * 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." diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateViewModel.kt index 3e27390..3c53fc2 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/UpdateViewModel.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.fabledsword.inkwell.AppUpdate import com.fabledsword.inkwell.UpdateOutcome import com.fabledsword.inkwell.core.ClientUpdate @@ -143,7 +145,7 @@ class UpdateViewModel( // Broad by intent, as everywhere the core is called: it reports // every failure as one error type carrying a message written to // 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 // 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) state.copy(downloading = false, ready = true) } 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. withContext(Dispatchers.IO) { AppUpdate.install(context, target) } } catch (e: Exception) { - e.message ?: FALLBACK + e.shownAs(FALLBACK) } // `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 @@ -220,11 +222,7 @@ class UpdateViewModel( core: Inkwell, context: Context, ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - UpdateViewModel(core, context.applicationContext) as T - } + viewModelFactory { initializer { UpdateViewModel(core, context.applicationContext) } } } }