feat: add undo/redo for character changes (Closes #89) (#131)

This commit was merged in pull request #131.
This commit is contained in:
2026-04-05 01:18:27 +02:00
parent a3fe0020ec
commit 28cae66994
7 changed files with 364 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# Plan: Issue #89 — Undo/Redo for Character Changes
## Summary
Implement an undo/redo stack in `CharacterViewModel` so that users can revert accidental character changes. The undo/redo buttons will be added to the top app bar. The stack tracks the last 20 character snapshots. Auto-save continues to save the current (post-undo/redo) state only.
## Implementation Steps
### Step 1: Create UndoRedoManager utility class
**File**: `sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/UndoRedoManager.kt`
Create a generic `UndoRedoManager<T>` class that:
- Maintains an undo stack (max 20 entries) and a redo stack
- Exposes `canUndo: StateFlow<Boolean>` and `canRedo: StateFlow<Boolean>`
- Methods: `pushState(state: T)`, `undo(current: T): T?`, `redo(current: T): T?`, `clear()`
- When a new state is pushed, the redo stack is cleared
- When the undo stack exceeds 20 entries, the oldest entry is dropped
### Step 2: Integrate UndoRedoManager into CharacterViewModel
**File**: `sharedUI/src/commonMain/kotlin/org/shahondin1624/viewmodel/CharacterViewModel.kt`
- Add an `UndoRedoManager<ShadowrunCharacter>` instance
- In `updateCharacter()` and `setCharacter()`: push the OLD state onto the undo stack before applying the new state
- Add `undo()` method: calls `undoRedoManager.undo(current)`, sets the result as current character (without pushing to undo stack)
- Add `redo()` method: calls `undoRedoManager.redo(current)`, sets the result as current character (without pushing to undo stack)
- Expose `canUndo: StateFlow<Boolean>` and `canRedo: StateFlow<Boolean>` from the manager
- Auto-save debounce continues to work normally -- it saves whatever the current state is
### Step 3: Add undo/redo buttons to the top app bar
**File**: `sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt`
- Add Undo and Redo `IconButton`s in the `actions` block of `TopAppBar`, before the save status indicator
- Use `Icons.AutoMirrored.Filled.Undo` and `Icons.AutoMirrored.Filled.Redo` (or `Icons.Default.Undo`/`Icons.Default.Redo`)
- Buttons are enabled/disabled based on `canUndo`/`canRedo` state
- Only show on the CHARACTER_SHEET route (not on creation or settings)
### Step 4: Add string resources
**File**: `sharedUI/src/commonMain/composeResources/values/strings.xml`
- Add `undo_content_desc` = "Undo"
- Add `redo_content_desc` = "Redo"
### Step 5: Add test tags
**File**: `sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt`
- Add `UNDO_BUTTON = "undo_button"`
- Add `REDO_BUTTON = "redo_button"`
### Step 6: Write unit tests for UndoRedoManager
**File**: `sharedUI/src/commonTest/kotlin/org/shahondin1624/viewmodel/UndoRedoManagerTest.kt`
Test cases:
- Initial state: canUndo=false, canRedo=false
- Push state -> canUndo=true, canRedo=false
- Undo returns previous state, canRedo=true
- Redo returns the state that was undone
- Push after undo clears redo stack
- Stack limited to 20 entries
- Clear resets everything
## AC Verification Checklist
1. [ ] Undo button available in the top bar (on character sheet route)
2. [ ] At least the last 10 changes can be undone (we support 20)
3. [ ] Redo is available after undo
4. [ ] Auto-save works with the undo stack (saves current state, not undo history)
@@ -37,6 +37,10 @@
<string name="top_bar_create_character">Create Character</string> <string name="top_bar_create_character">Create Character</string>
<string name="top_bar_settings">Settings</string> <string name="top_bar_settings">Settings</string>
<!-- Undo/Redo -->
<string name="undo_content_desc">Undo</string>
<string name="redo_content_desc">Redo</string>
<!-- Save status --> <!-- Save status -->
<string name="save_status_saving">Saving...</string> <string name="save_status_saving">Saving...</string>
<string name="save_status_saved">Saved</string> <string name="save_status_saved">Saved</string>
@@ -10,7 +10,9 @@ import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Redo
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Undo
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -288,6 +290,8 @@ private fun MainScaffold(
val character by characterViewModel.character.collectAsState() val character by characterViewModel.character.collectAsState()
val saveStatus by characterViewModel.saveStatus.collectAsState() val saveStatus by characterViewModel.saveStatus.collectAsState()
val loadStatus by characterViewModel.loadStatus.collectAsState() val loadStatus by characterViewModel.loadStatus.collectAsState()
val canUndo by characterViewModel.canUndo.collectAsState()
val canRedo by characterViewModel.canRedo.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Show load error dialog when deserialization fails // Show load error dialog when deserialization fails
@@ -366,6 +370,28 @@ private fun MainScaffold(
} }
}, },
actions = { actions = {
if (currentRoute == AppRoutes.CHARACTER_SHEET) {
IconButton(
onClick = { characterViewModel.undo() },
enabled = canUndo,
modifier = Modifier.testTag(TestTags.UNDO_BUTTON)
) {
Icon(
Icons.Default.Undo,
contentDescription = stringResource(Res.string.undo_content_desc)
)
}
IconButton(
onClick = { characterViewModel.redo() },
enabled = canRedo,
modifier = Modifier.testTag(TestTags.REDO_BUTTON)
) {
Icon(
Icons.Default.Redo,
contentDescription = stringResource(Res.string.redo_content_desc)
)
}
}
SaveStatusIndicator(status = saveStatus) SaveStatusIndicator(status = saveStatus)
IconButton( IconButton(
onClick = { showHistoryDialog = true }, onClick = { showHistoryDialog = true },
@@ -144,6 +144,10 @@ object TestTags {
const val SAVE_STATUS_ERROR = "save_status_error" const val SAVE_STATUS_ERROR = "save_status_error"
const val SAVE_ERROR_SNACKBAR = "save_error_snackbar" const val SAVE_ERROR_SNACKBAR = "save_error_snackbar"
// --- Undo/Redo ---
const val UNDO_BUTTON = "undo_button"
const val REDO_BUTTON = "redo_button"
// --- Top app bar --- // --- Top app bar ---
const val TOP_APP_BAR = "top_app_bar" const val TOP_APP_BAR = "top_app_bar"
const val THEME_TOGGLE = "theme_toggle" const val THEME_TOGGLE = "theme_toggle"
@@ -36,6 +36,14 @@ class CharacterViewModel : ViewModel() {
/** Observable load status — UI should check for [LoadStatus.Error] to show recovery dialog. */ /** Observable load status — UI should check for [LoadStatus.Error] to show recovery dialog. */
val loadStatus: StateFlow<LoadStatus> = _loadStatus.asStateFlow() val loadStatus: StateFlow<LoadStatus> = _loadStatus.asStateFlow()
private val undoRedoManager = UndoRedoManager<ShadowrunCharacter>()
/** Whether an undo operation is available. */
val canUndo: StateFlow<Boolean> = undoRedoManager.canUndo
/** Whether a redo operation is available. */
val canRedo: StateFlow<Boolean> = undoRedoManager.canRedo
private val _saveStatus = MutableStateFlow<SaveStatus>(SaveStatus.Idle) private val _saveStatus = MutableStateFlow<SaveStatus>(SaveStatus.Idle)
/** Observable save status for UI display. */ /** Observable save status for UI display. */
@@ -57,6 +65,7 @@ class CharacterViewModel : ViewModel() {
*/ */
fun setCharacter(character: ShadowrunCharacter) { fun setCharacter(character: ShadowrunCharacter) {
Napier.i(tag = TAG) { "Character replaced: ${character.characterData.name}" } Napier.i(tag = TAG) { "Character replaced: ${character.characterData.name}" }
undoRedoManager.pushState(_character.value)
_character.value = character _character.value = character
} }
@@ -64,9 +73,32 @@ class CharacterViewModel : ViewModel() {
* Update the character by applying a transformation function. * Update the character by applying a transformation function.
*/ */
fun updateCharacter(transform: (ShadowrunCharacter) -> ShadowrunCharacter) { fun updateCharacter(transform: (ShadowrunCharacter) -> ShadowrunCharacter) {
undoRedoManager.pushState(_character.value)
_character.value = transform(_character.value) _character.value = transform(_character.value)
} }
/**
* Undo the last character change, restoring the previous state.
*/
fun undo() {
val previous = undoRedoManager.undo(_character.value)
if (previous != null) {
Napier.i(tag = TAG) { "Undo: restored character '${previous.characterData.name}'" }
_character.value = previous
}
}
/**
* Redo the last undone change.
*/
fun redo() {
val redone = undoRedoManager.redo(_character.value)
if (redone != null) {
Napier.i(tag = TAG) { "Redo: restored character '${redone.characterData.name}'" }
_character.value = redone
}
}
/** /**
* Save the current character immediately (synchronous). * Save the current character immediately (synchronous).
* Also updates the save status indicator. * Also updates the save status indicator.
@@ -0,0 +1,89 @@
package org.shahondin1624.viewmodel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Generic undo/redo manager that maintains a bounded history of states.
*
* @param maxSize Maximum number of undo entries to retain (oldest are evicted when exceeded).
*/
class UndoRedoManager<T>(private val maxSize: Int = DEFAULT_MAX_SIZE) {
private val undoStack = ArrayDeque<T>()
private val redoStack = ArrayDeque<T>()
private val _canUndo = MutableStateFlow(false)
val canUndo: StateFlow<Boolean> = _canUndo.asStateFlow()
private val _canRedo = MutableStateFlow(false)
val canRedo: StateFlow<Boolean> = _canRedo.asStateFlow()
/**
* Push the current state onto the undo stack before a new change is applied.
* Clears the redo stack since the timeline has diverged.
*/
fun pushState(state: T) {
undoStack.addLast(state)
if (undoStack.size > maxSize) {
undoStack.removeFirst()
}
redoStack.clear()
updateFlows()
}
/**
* Undo the last change by popping the most recent state from the undo stack.
* Pushes [current] onto the redo stack so it can be restored later.
*
* @param current The current state (before undo) to push onto redo.
* @return The previous state to restore, or null if nothing to undo.
*/
fun undo(current: T): T? {
if (undoStack.isEmpty()) return null
redoStack.addLast(current)
val previous = undoStack.removeLast()
updateFlows()
return previous
}
/**
* Redo the last undone change by popping from the redo stack.
* Pushes [current] onto the undo stack.
*
* @param current The current state (before redo) to push onto undo.
* @return The redone state to restore, or null if nothing to redo.
*/
fun redo(current: T): T? {
if (redoStack.isEmpty()) return null
undoStack.addLast(current)
val redone = redoStack.removeLast()
updateFlows()
return redone
}
/**
* Clear all undo and redo history.
*/
fun clear() {
undoStack.clear()
redoStack.clear()
updateFlows()
}
/** Current number of undo entries (exposed for testing). */
val undoSize: Int get() = undoStack.size
/** Current number of redo entries (exposed for testing). */
val redoSize: Int get() = redoStack.size
private fun updateFlows() {
_canUndo.value = undoStack.isNotEmpty()
_canRedo.value = redoStack.isNotEmpty()
}
companion object {
const val DEFAULT_MAX_SIZE = 20
}
}
@@ -0,0 +1,145 @@
package org.shahondin1624
import org.shahondin1624.viewmodel.UndoRedoManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class UndoRedoManagerTest {
@Test
fun initialState_cannotUndoOrRedo() {
val manager = UndoRedoManager<String>()
assertFalse(manager.canUndo.value)
assertFalse(manager.canRedo.value)
}
@Test
fun pushState_enablesUndo() {
val manager = UndoRedoManager<String>()
manager.pushState("A")
assertTrue(manager.canUndo.value)
assertFalse(manager.canRedo.value)
}
@Test
fun undo_returnsPreviousState() {
val manager = UndoRedoManager<String>()
manager.pushState("A")
val result = manager.undo("B")
assertEquals("A", result)
}
@Test
fun undo_enablesRedo() {
val manager = UndoRedoManager<String>()
manager.pushState("A")
manager.undo("B")
assertTrue(manager.canRedo.value)
}
@Test
fun redo_returnsUndoneState() {
val manager = UndoRedoManager<String>()
manager.pushState("A")
manager.undo("B") // undo from B to A, B goes to redo
val result = manager.redo("A") // redo from A, should get B
assertEquals("B", result)
}
@Test
fun pushAfterUndo_clearsRedoStack() {
val manager = UndoRedoManager<String>()
manager.pushState("A")
manager.undo("B")
assertTrue(manager.canRedo.value)
manager.pushState("C") // new change should clear redo
assertFalse(manager.canRedo.value)
}
@Test
fun undo_whenEmpty_returnsNull() {
val manager = UndoRedoManager<String>()
val result = manager.undo("current")
assertNull(result)
}
@Test
fun redo_whenEmpty_returnsNull() {
val manager = UndoRedoManager<String>()
val result = manager.redo("current")
assertNull(result)
}
@Test
fun stackLimitedToMaxSize() {
val manager = UndoRedoManager<Int>(maxSize = 3)
manager.pushState(1)
manager.pushState(2)
manager.pushState(3)
manager.pushState(4) // should evict 1
assertEquals(3, manager.undoSize)
// Undo three times: should get 4, 3, 2 (not 1)
val third = manager.undo(5) // gets 4
assertEquals(4, third)
val second = manager.undo(4) // gets 3
assertEquals(3, second)
val first = manager.undo(3) // gets 2
assertEquals(2, first)
val empty = manager.undo(2) // nothing left
assertNull(empty)
}
@Test
fun clear_resetsEverything() {
val manager = UndoRedoManager<String>()
manager.pushState("A")
manager.pushState("B")
manager.undo("C")
assertTrue(manager.canUndo.value)
assertTrue(manager.canRedo.value)
manager.clear()
assertFalse(manager.canUndo.value)
assertFalse(manager.canRedo.value)
assertEquals(0, manager.undoSize)
assertEquals(0, manager.redoSize)
}
@Test
fun multipleUndoRedo_maintainsCorrectOrder() {
val manager = UndoRedoManager<String>()
// Simulate: start with "A", change to "B", then "C"
manager.pushState("A") // push A before changing to B
manager.pushState("B") // push B before changing to C
// Current state is "C"
// Undo from C -> B
val undone1 = manager.undo("C")
assertEquals("B", undone1)
// Undo from B -> A
val undone2 = manager.undo("B")
assertEquals("A", undone2)
// Redo from A -> B
val redone1 = manager.redo("A")
assertEquals("B", redone1)
// Redo from B -> C
val redone2 = manager.redo("B")
assertEquals("C", redone2)
}
@Test
fun defaultMaxSizeIs20() {
val manager = UndoRedoManager<Int>()
// Push 25 states
for (i in 1..25) {
manager.pushState(i)
}
assertEquals(20, manager.undoSize)
}
}