This commit was merged in pull request #74.
This commit is contained in:
@@ -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++
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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