feat: add in-memory dice roll history (Closes #17) (#74)

This commit was merged in pull request #74.
This commit is contained in:
2026-03-13 14:21:43 +01:00
parent a6a8d56962
commit 7a020cbdc5
6 changed files with 355 additions and 1 deletions

View File

@@ -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++
}
)
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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<DiceRollHistoryEntry>,
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
)
}
}
}

View File

@@ -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<Int>,
/** 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<DiceRollHistoryEntry>()
private var nextSequence = 1L
/** Current history entries in reverse chronological order (most recent first). */
val entries: List<DiceRollHistoryEntry> 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()
}
}

View File

@@ -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<Int> = 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)
}
}