369 lines
13 KiB
Kotlin
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)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|