feat: add in-memory dice roll history (Closes #17) #74
@@ -3,6 +3,7 @@ package org.shahondin1624
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.DarkMode
|
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.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
|
||||||
@@ -12,6 +13,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
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.TestTags
|
||||||
import org.shahondin1624.lib.components.UiConstants
|
import org.shahondin1624.lib.components.UiConstants
|
||||||
import org.shahondin1624.lib.components.charactermodel.CharacterSheetPage
|
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.lib.components.settings.SettingsPage
|
||||||
import org.shahondin1624.navigation.AppRoutes
|
import org.shahondin1624.navigation.AppRoutes
|
||||||
import org.shahondin1624.theme.AppTheme
|
import org.shahondin1624.theme.AppTheme
|
||||||
@@ -248,6 +253,26 @@ private fun MainScaffold(
|
|||||||
val currentRoute = currentRoute(navController)
|
val currentRoute = currentRoute(navController)
|
||||||
val character by characterViewModel.character.collectAsState()
|
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) {
|
val topBarTitle = when (currentRoute) {
|
||||||
AppRoutes.CHARACTER_SHEET -> character.characterData.name
|
AppRoutes.CHARACTER_SHEET -> character.characterData.name
|
||||||
AppRoutes.SETTINGS -> "Settings"
|
AppRoutes.SETTINGS -> "Settings"
|
||||||
@@ -271,6 +296,12 @@ private fun MainScaffold(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showHistoryDialog = true },
|
||||||
|
modifier = Modifier.testTag(TestTags.DICE_HISTORY_BUTTON)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.History, contentDescription = "Roll History")
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val newPref = if (isDark) ThemePreference.Light else ThemePreference.Dark
|
val newPref = if (isDark) ThemePreference.Light else ThemePreference.Dark
|
||||||
@@ -316,6 +347,10 @@ private fun MainScaffold(
|
|||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
onUpdateCharacter = { transform ->
|
onUpdateCharacter = { transform ->
|
||||||
characterViewModel.updateCharacter(transform)
|
characterViewModel.updateCharacter(transform)
|
||||||
|
},
|
||||||
|
onRecordRoll = { roll, label ->
|
||||||
|
diceRollHistory.record(label, roll)
|
||||||
|
historyVersion++
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ object TestTags {
|
|||||||
const val IMPORT_DISMISS = "import_dismiss"
|
const val IMPORT_DISMISS = "import_dismiss"
|
||||||
const val IMPORT_ERROR = "import_error"
|
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 ---
|
// --- 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"
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ private enum class CharacterTab(val title: String) {
|
|||||||
fun CharacterSheetPage(
|
fun CharacterSheetPage(
|
||||||
character: ShadowrunCharacter,
|
character: ShadowrunCharacter,
|
||||||
contentPadding: Dp,
|
contentPadding: Dp,
|
||||||
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {}
|
onUpdateCharacter: ((ShadowrunCharacter) -> ShadowrunCharacter) -> Unit = {},
|
||||||
|
onRecordRoll: ((DiceRoll, String) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val windowSizeClass = LocalWindowSizeClass.current
|
val windowSizeClass = LocalWindowSizeClass.current
|
||||||
var selectedTab by remember { mutableStateOf(CharacterTab.Overview) }
|
var selectedTab by remember { mutableStateOf(CharacterTab.Overview) }
|
||||||
@@ -51,6 +52,7 @@ fun CharacterSheetPage(
|
|||||||
val onDiceRoll: (DiceRoll, String) -> Unit = { roll, label ->
|
val onDiceRoll: (DiceRoll, String) -> Unit = { roll, label ->
|
||||||
pendingRoll = roll
|
pendingRoll = roll
|
||||||
pendingRollLabel = label
|
pendingRollLabel = label
|
||||||
|
onRecordRoll?.invoke(roll, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attribute edit dialog state
|
// Attribute edit dialog state
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user