From ed0526ba5bcf6823eb002e959ce3bf1aa5ddd796 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 14:21:25 +0100 Subject: [PATCH] feat: add in-memory dice roll history with toolbar access (Closes #17) DiceRollHistory stores the last 20 rolls in reverse chronological order (label, pool size, results, successes, sequence number). A History icon in the top app bar opens a dialog listing all rolls. CharacterSheetPage records each dice roll via onRecordRoll callback. Includes 9 unit tests for history ordering, capacity, and clearing. Co-Authored-By: Claude Opus 4.6 --- .../kotlin/org/shahondin1624/App.kt | 35 +++++ .../shahondin1624/lib/components/TestTags.kt | 8 ++ .../charactermodel/CharacterSheetPage.kt | 4 +- .../charactermodel/DiceRollHistoryDialog.kt | 135 ++++++++++++++++++ .../lib/functions/DiceRollHistory.kt | 55 +++++++ .../org/shahondin1624/DiceRollHistoryTest.kt | 119 +++++++++++++++ 6 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DiceRollHistoryDialog.kt create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/functions/DiceRollHistory.kt create mode 100644 sharedUI/src/commonTest/kotlin/org/shahondin1624/DiceRollHistoryTest.kt diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index 6c29b12..df6d52e 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -3,6 +3,7 @@ package org.shahondin1624 import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Person @@ -12,6 +13,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -29,6 +32,8 @@ import org.jetbrains.compose.ui.tooling.preview.Preview import org.shahondin1624.lib.components.TestTags import org.shahondin1624.lib.components.UiConstants import org.shahondin1624.lib.components.charactermodel.CharacterSheetPage +import org.shahondin1624.lib.components.charactermodel.DiceRollHistoryDialog +import org.shahondin1624.lib.functions.DiceRollHistory import org.shahondin1624.lib.components.settings.SettingsPage import org.shahondin1624.navigation.AppRoutes import org.shahondin1624.theme.AppTheme @@ -248,6 +253,26 @@ private fun MainScaffold( val currentRoute = currentRoute(navController) val character by characterViewModel.character.collectAsState() + // Dice roll history (in-memory, not persisted) + val diceRollHistory = remember { DiceRollHistory() } + var showHistoryDialog by remember { mutableStateOf(false) } + // Trigger recomposition when history changes + var historyVersion by remember { mutableStateOf(0) } + + if (showHistoryDialog) { + // Read historyVersion to trigger recomposition when entries change + @Suppress("UNUSED_EXPRESSION") + historyVersion + DiceRollHistoryDialog( + entries = diceRollHistory.entries, + onClear = { + diceRollHistory.clear() + historyVersion++ + }, + onDismiss = { showHistoryDialog = false } + ) + } + val topBarTitle = when (currentRoute) { AppRoutes.CHARACTER_SHEET -> character.characterData.name AppRoutes.SETTINGS -> "Settings" @@ -271,6 +296,12 @@ private fun MainScaffold( } }, actions = { + IconButton( + onClick = { showHistoryDialog = true }, + modifier = Modifier.testTag(TestTags.DICE_HISTORY_BUTTON) + ) { + Icon(Icons.Default.History, contentDescription = "Roll History") + } IconButton( onClick = { val newPref = if (isDark) ThemePreference.Light else ThemePreference.Dark @@ -316,6 +347,10 @@ private fun MainScaffold( contentPadding = contentPadding, onUpdateCharacter = { transform -> characterViewModel.updateCharacter(transform) + }, + onRecordRoll = { roll, label -> + diceRollHistory.record(label, roll) + historyVersion++ } ) } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 4dc8b35..6413ee5 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -96,6 +96,14 @@ object TestTags { const val IMPORT_DISMISS = "import_dismiss" const val IMPORT_ERROR = "import_error" + // --- Dice roll history --- + const val DICE_HISTORY_BUTTON = "dice_history_button" + const val DICE_HISTORY_DIALOG = "dice_history_dialog" + const val DICE_HISTORY_EMPTY = "dice_history_empty" + const val DICE_HISTORY_DISMISS = "dice_history_dismiss" + const val DICE_HISTORY_CLEAR = "dice_history_clear" + fun diceHistoryEntry(index: Int): String = "dice_history_entry_$index" + // --- Top app bar --- const val TOP_APP_BAR = "top_app_bar" const val THEME_TOGGLE = "theme_toggle" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt index 1f29de9..940ae9e 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/CharacterSheetPage.kt @@ -38,7 +38,8 @@ private enum class CharacterTab(val title: String) { fun CharacterSheetPage( character: ShadowrunCharacter, contentPadding: Dp, - onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {} + onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {}, + onRecordRoll: ((DiceRoll, String) -> Unit)? = null ) { val windowSizeClass = LocalWindowSizeClass.current var selectedTab by remember { mutableStateOf(CharacterTab.Overview) } @@ -51,6 +52,7 @@ fun CharacterSheetPage( val onDiceRoll: (DiceRoll, String) -> Unit = { roll, label -> pendingRoll = roll pendingRollLabel = label + onRecordRoll?.invoke(roll, label) } // Attribute edit dialog state diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DiceRollHistoryDialog.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DiceRollHistoryDialog.kt new file mode 100644 index 0000000..97174a8 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/DiceRollHistoryDialog.kt @@ -0,0 +1,135 @@ +package org.shahondin1624.lib.components.charactermodel + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.lib.functions.DiceRollHistoryEntry + +/** + * Dialog displaying the dice roll history in reverse chronological order. + */ +@Composable +fun DiceRollHistoryDialog( + entries: List, + onClear: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TestTags.DICE_HISTORY_DIALOG), + title = { Text("Roll History") }, + text = { + if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No rolls yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(TestTags.DICE_HISTORY_EMPTY) + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(entries) { index, entry -> + DiceRollHistoryItem( + entry = entry, + rollNumber = entries.size - index, + modifier = Modifier.testTag(TestTags.diceHistoryEntry(index)) + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag(TestTags.DICE_HISTORY_DISMISS) + ) { + Text("Close") + } + }, + dismissButton = { + if (entries.isNotEmpty()) { + TextButton( + onClick = onClear, + modifier = Modifier.testTag(TestTags.DICE_HISTORY_CLEAR) + ) { + Text("Clear") + } + } + } + ) +} + +@Composable +private fun DiceRollHistoryItem( + entry: DiceRollHistoryEntry, + rollNumber: Int, + modifier: Modifier = Modifier +) { + Card(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = entry.label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "#$rollNumber", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Pool: ${entry.poolSize}d6", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${entry.successes} hit${if (entry.successes != 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = entry.results.joinToString(", ") { "[$it]" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/functions/DiceRollHistory.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/functions/DiceRollHistory.kt new file mode 100644 index 0000000..74e02a9 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/functions/DiceRollHistory.kt @@ -0,0 +1,55 @@ +package org.shahondin1624.lib.functions + +/** + * A single entry in the dice roll history log. + */ +data class DiceRollHistoryEntry( + /** What was rolled (e.g., "Body", "Firearms") */ + val label: String, + /** Pool size (number of dice) */ + val poolSize: Int, + /** Individual die results */ + val results: List, + /** Number of successes (hits) */ + val successes: Int, + /** Monotonic sequence number for ordering (higher = more recent) */ + val sequenceNumber: Long +) + +/** + * In-memory dice roll history holding the last [maxEntries] rolls. + * Not persisted across app restarts. Entries are in reverse chronological order. + */ +class DiceRollHistory(private val maxEntries: Int = 20) { + private val _entries = mutableListOf() + private var nextSequence = 1L + + /** Current history entries in reverse chronological order (most recent first). */ + val entries: List get() = _entries.toList() + + /** Number of entries currently stored. */ + val size: Int get() = _entries.size + + /** + * Record a dice roll to history. + * Oldest entries are dropped when capacity is exceeded. + */ + fun record(label: String, roll: DiceRoll) { + val entry = DiceRollHistoryEntry( + label = label, + poolSize = roll.numberOfDice, + results = roll.result, + successes = roll.numberOfSuccesses, + sequenceNumber = nextSequence++ + ) + _entries.add(0, entry) // Add to front for reverse chronological + if (_entries.size > maxEntries) { + _entries.removeAt(_entries.lastIndex) + } + } + + /** Clear all history. */ + fun clear() { + _entries.clear() + } +} diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/DiceRollHistoryTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/DiceRollHistoryTest.kt new file mode 100644 index 0000000..78463ca --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/DiceRollHistoryTest.kt @@ -0,0 +1,119 @@ +package org.shahondin1624 + +import org.shahondin1624.lib.functions.DiceRoll +import org.shahondin1624.lib.functions.DiceRollHistory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DiceRollHistoryTest { + + private fun makeRoll(dice: Int = 6, results: List = listOf(5, 3, 6, 1, 2, 4), successes: Int = 2): DiceRoll { + return DiceRoll( + numberOfDice = dice, + result = results, + numberOfSuccesses = successes + ) + } + + @Test + fun emptyHistoryHasNoEntries() { + val history = DiceRollHistory() + assertTrue(history.entries.isEmpty()) + assertEquals(0, history.size) + } + + @Test + fun recordAddsEntry() { + val history = DiceRollHistory() + history.record("Body", makeRoll()) + assertEquals(1, history.size) + assertEquals("Body", history.entries[0].label) + } + + @Test + fun entriesAreInReverseChronologicalOrder() { + val history = DiceRollHistory() + history.record("Body", makeRoll()) + history.record("Agility", makeRoll()) + history.record("Strength", makeRoll()) + + assertEquals("Strength", history.entries[0].label) // most recent first + assertEquals("Agility", history.entries[1].label) + assertEquals("Body", history.entries[2].label) + } + + @Test + fun sequenceNumbersAreIncreasing() { + val history = DiceRollHistory() + history.record("First", makeRoll()) + history.record("Second", makeRoll()) + history.record("Third", makeRoll()) + + // Most recent first, so highest sequence number first + assertTrue(history.entries[0].sequenceNumber > history.entries[1].sequenceNumber) + assertTrue(history.entries[1].sequenceNumber > history.entries[2].sequenceNumber) + } + + @Test + fun entryContainsCorrectData() { + val history = DiceRollHistory() + val roll = DiceRoll( + numberOfDice = 8, + result = listOf(5, 6, 3, 1, 4, 5, 2, 6), + numberOfSuccesses = 4 + ) + history.record("Firearms", roll) + + val entry = history.entries[0] + assertEquals("Firearms", entry.label) + assertEquals(8, entry.poolSize) + assertEquals(listOf(5, 6, 3, 1, 4, 5, 2, 6), entry.results) + assertEquals(4, entry.successes) + } + + @Test + fun maxEntriesIsRespected() { + val history = DiceRollHistory(maxEntries = 3) + history.record("Roll 1", makeRoll()) + history.record("Roll 2", makeRoll()) + history.record("Roll 3", makeRoll()) + history.record("Roll 4", makeRoll()) + + assertEquals(3, history.size) + // Oldest (Roll 1) should have been dropped + assertEquals("Roll 4", history.entries[0].label) + assertEquals("Roll 3", history.entries[1].label) + assertEquals("Roll 2", history.entries[2].label) + } + + @Test + fun defaultMaxIs20() { + val history = DiceRollHistory() + repeat(25) { i -> + history.record("Roll $i", makeRoll()) + } + assertEquals(20, history.size) + } + + @Test + fun clearRemovesAllEntries() { + val history = DiceRollHistory() + history.record("Roll 1", makeRoll()) + history.record("Roll 2", makeRoll()) + history.clear() + assertTrue(history.entries.isEmpty()) + assertEquals(0, history.size) + } + + @Test + fun entriesListIsDefensiveCopy() { + val history = DiceRollHistory() + history.record("Roll 1", makeRoll()) + val snapshot = history.entries + history.record("Roll 2", makeRoll()) + // Original snapshot should not have changed + assertEquals(1, snapshot.size) + assertEquals(2, history.entries.size) + } +}