Files
ShadowrunCharSheet/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt

369 lines
13 KiB
Kotlin

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
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.launch
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
import org.shahondin1624.theme.LocalThemeIsDark
import org.shahondin1624.theme.LocalThemePreference
import org.shahondin1624.theme.LocalWindowSizeClass
import org.shahondin1624.theme.ThemePreference
import org.shahondin1624.theme.WindowSizeClass
import org.shahondin1624.viewmodel.CharacterViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun App(
onThemeChanged: @Composable (isDark: Boolean) -> Unit = {}
) = AppTheme(onThemeChanged) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val windowSizeClass = WindowSizeClass.fromWidth(maxWidth)
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
val navController = rememberNavController()
val characterViewModel: CharacterViewModel = viewModel { CharacterViewModel() }
AppContent(navController, characterViewModel)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppContent(navController: NavHostController, characterViewModel: CharacterViewModel) {
val windowSizeClass = LocalWindowSizeClass.current
when (windowSizeClass) {
WindowSizeClass.Compact -> CompactNavigation(navController, characterViewModel)
WindowSizeClass.Medium -> MediumNavigation(navController, characterViewModel)
WindowSizeClass.Expanded -> ExpandedNavigation(navController, characterViewModel)
}
}
/**
* Returns the current route from the NavController's back stack.
*/
@Composable
private fun currentRoute(navController: NavHostController): String? {
val navBackStackEntry by navController.currentBackStackEntryAsState()
return navBackStackEntry?.destination?.route
}
/**
* Navigate to a route, popping back to the start destination to avoid
* building up a deep back stack.
*/
private fun navigateTo(navController: NavHostController, route: String) {
navController.navigate(route) {
popUpTo(AppRoutes.CHARACTER_SHEET) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
/**
* Compact layout: ModalNavigationDrawer with hamburger menu button.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompactNavigation(navController: NavHostController, characterViewModel: CharacterViewModel) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val currentRoute = currentRoute(navController)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
DrawerContent(
currentRoute = currentRoute,
onNavigate = { route ->
navigateTo(navController, route)
scope.launch { drawerState.close() }
}
)
}
}
) {
MainScaffold(
navController = navController,
characterViewModel = characterViewModel,
showMenuButton = true,
onMenuClick = {
scope.launch {
if (drawerState.isClosed) drawerState.open() else drawerState.close()
}
}
)
}
}
/**
* Medium layout: NavigationRail on the left side with main content.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MediumNavigation(navController: NavHostController, characterViewModel: CharacterViewModel) {
val currentRoute = currentRoute(navController)
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail(
modifier = Modifier.testTag(TestTags.NAV_RAIL)
) {
Spacer(Modifier.height(8.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Character") },
selected = currentRoute == AppRoutes.CHARACTER_SHEET,
onClick = { navigateTo(navController, AppRoutes.CHARACTER_SHEET) },
modifier = Modifier.testTag(TestTags.NAV_CHARACTER_SHEET)
)
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
selected = currentRoute == AppRoutes.SETTINGS,
onClick = { navigateTo(navController, AppRoutes.SETTINGS) },
modifier = Modifier.testTag(TestTags.NAV_SETTINGS)
)
}
MainScaffold(
navController = navController,
characterViewModel = characterViewModel,
showMenuButton = false,
onMenuClick = {},
modifier = Modifier.weight(1f)
)
}
}
/**
* Expanded layout: PermanentNavigationDrawer always visible.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExpandedNavigation(navController: NavHostController, characterViewModel: CharacterViewModel) {
val currentRoute = currentRoute(navController)
PermanentNavigationDrawer(
drawerContent = {
PermanentDrawerSheet(modifier = Modifier.testTag(TestTags.NAV_PERMANENT_DRAWER)) {
DrawerContent(
currentRoute = currentRoute,
onNavigate = { route -> navigateTo(navController, route) }
)
}
}
) {
MainScaffold(
navController = navController,
characterViewModel = characterViewModel,
showMenuButton = false,
onMenuClick = {}
)
}
}
/**
* Shared drawer content used by Compact (ModalDrawerSheet) and Expanded (PermanentDrawerSheet).
*/
@Composable
private fun DrawerContent(
currentRoute: String?,
onNavigate: (String) -> Unit
) {
Spacer(Modifier.height(16.dp))
Text(
"Menu",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleLarge
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Character Sheet") },
selected = currentRoute == AppRoutes.CHARACTER_SHEET,
onClick = { onNavigate(AppRoutes.CHARACTER_SHEET) },
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
.testTag(TestTags.NAV_CHARACTER_SHEET)
)
NavigationDrawerItem(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") },
selected = currentRoute == AppRoutes.SETTINGS,
onClick = { onNavigate(AppRoutes.SETTINGS) },
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
.testTag(TestTags.NAV_SETTINGS)
)
}
/**
* Main scaffold with top app bar and routed content area.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScaffold(
navController: NavHostController,
characterViewModel: CharacterViewModel,
showMenuButton: Boolean,
onMenuClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isDark by LocalThemeIsDark.current
var themePreference by LocalThemePreference.current
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"
else -> "Shadowrun Character Sheet"
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(topBarTitle) },
modifier = Modifier.testTag(TestTags.TOP_APP_BAR),
navigationIcon = {
if (showMenuButton) {
IconButton(
onClick = onMenuClick,
modifier = Modifier.testTag(TestTags.MENU_BUTTON)
) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
}
},
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
themePreference = newPref
ThemePreference.save(newPref)
},
modifier = Modifier.testTag(TestTags.THEME_TOGGLE)
) {
Icon(
imageVector = if (isDark) {
Icons.Default.LightMode
} else {
Icons.Default.DarkMode
},
contentDescription = "Toggle ${if (isDark) "Light" else "Dark"} mode"
)
}
}
)
}
) { paddingValues ->
val windowSizeClass = LocalWindowSizeClass.current
val contentPadding = UiConstants.Spacing.large(windowSizeClass)
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter
) {
NavHost(
navController = navController,
startDestination = AppRoutes.CHARACTER_SHEET,
modifier = Modifier
.widthIn(max = UiConstants.MAX_CONTENT_WIDTH)
.fillMaxWidth()
.padding(contentPadding)
) {
composable(AppRoutes.CHARACTER_SHEET) {
CharacterSheetPage(
character = character,
contentPadding = contentPadding,
onUpdateCharacter = { transform ->
characterViewModel.updateCharacter(transform)
},
onRecordRoll = { roll, label ->
diceRollHistory.record(label, roll)
historyVersion++
}
)
}
composable(AppRoutes.SETTINGS) {
SettingsPage(
character = character,
onImportCharacter = { imported ->
characterViewModel.setCharacter(imported)
}
)
}
}
}
}
}