diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index d15a198..eb6560b 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -73,13 +73,14 @@ import com.scarriffle.calendarr.ui.event.EventDetailScreen import com.scarriffle.calendarr.ui.event.EventEditorSheet import com.scarriffle.calendarr.ui.groups.GroupIcon import com.scarriffle.calendarr.ui.groups.GroupsScreen +import com.scarriffle.calendarr.ui.menu.MenuScreen import com.scarriffle.calendarr.ui.profile.ProfileScreen import com.scarriffle.calendarr.ui.settings.SettingsScreen import com.scarriffle.calendarr.ui.tr import kotlinx.coroutines.launch import java.time.LocalDate -private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS } +private enum class Overlay { NONE, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS } data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null) @@ -198,7 +199,7 @@ fun CalendarScreen( CalendarDrawerContent( state = state, vm = vm, username = username, serverUrl = serverUrl, calendars = drawerCalendars, - onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.SETTINGS }, + onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.MENU }, onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() }, onClose = { drawerScope.launch { drawerState.close() } }, ) @@ -363,23 +364,30 @@ fun CalendarScreen( } when (overlay) { - Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE }) - Overlay.SETTINGS -> SettingsScreen( - onClose = { overlay = Overlay.NONE; vm.refreshMonthViewMode() }, - onSettingsChanged = onSettingsChanged, - onSettingsSynced = onSettingsSynced, - onOpenProfile = { overlay = Overlay.PROFILE }, - onOpenAccounts = { overlay = Overlay.ACCOUNTS }, - onOpenGroups = { overlay = Overlay.GROUPS }, + Overlay.MENU -> MenuScreen( + username = username, + serverUrl = serverUrl, + onClose = { overlay = Overlay.NONE }, + onProfile = { overlay = Overlay.PROFILE }, + onAppearance = { overlay = Overlay.SETTINGS }, + onAccounts = { overlay = Overlay.ACCOUNTS }, + onGroups = { overlay = Overlay.GROUPS }, + onSync = { overlay = Overlay.NONE; vm.syncWithServer() }, onSwitchServer = onSwitchServer, onLogout = onLogout, ) + Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.MENU }) + Overlay.SETTINGS -> SettingsScreen( + onClose = { overlay = Overlay.MENU; vm.refreshMonthViewMode() }, + onSettingsChanged = onSettingsChanged, + onSettingsSynced = onSettingsSynced, + ) Overlay.ACCOUNTS -> AccountsScreen( - onClose = { overlay = Overlay.NONE }, + onClose = { overlay = Overlay.MENU }, onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, ) Overlay.GROUPS -> GroupsScreen( - onClose = { overlay = Overlay.NONE }, + onClose = { overlay = Overlay.MENU }, onChanged = { vm.loadGroups(); vm.loadWritableCalendars() }, onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) }, ) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt new file mode 100644 index 0000000..8e9e52f --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt @@ -0,0 +1,124 @@ +package com.scarriffle.calendarr.ui.menu + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.ui.tr + +/** Full-screen menu / landing page reached from the drawer's gear: the hub for + * Profile / Appearance / Accounts / Groups / Sync / Server / Logout. Each entry + * opens its own full-screen page. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MenuScreen( + username: String, + serverUrl: String, + onClose: () -> Unit, + onProfile: () -> Unit, + onAppearance: () -> Unit, + onAccounts: () -> Unit, + onGroups: () -> Unit, + onSync: () -> Unit, + onSwitchServer: () -> Unit, + onLogout: () -> Unit, +) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("nav.menu")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + contentWindowInsets = WindowInsets(0), + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).navigationBarsPadding(), + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text(username.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.width(14.dp)) + Column { + Text(username, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1) + Text(serverUrl.removePrefix("https://").removePrefix("http://"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + Divider() + MenuRow(Icons.Filled.Person, tr("menu.profile"), onProfile) + MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) + MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) + MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups) + Divider(Modifier.padding(vertical = 4.dp)) + MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) + MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer) + MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout, destructive = true) + } + } + } +} + +@Composable +private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit, destructive: Boolean = false) { + val tint = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = tint) + Spacer(Modifier.width(18.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, + color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt deleted file mode 100644 index 93fc532..0000000 --- a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.scarriffle.calendarr.ui.menu - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.Logout -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material3.Divider -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.scarriffle.calendarr.domain.model.Group -import com.scarriffle.calendarr.ui.groups.GroupIcon -import com.scarriffle.calendarr.ui.tr - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MenuSheet( - isAdmin: Boolean, - groups: List, - activeGroup: Group?, - onSwitchGroup: (Group?) -> Unit, - onDismiss: () -> Unit, - onProfile: () -> Unit, - onAppearance: () -> Unit, - onAccounts: () -> Unit, - onGroups: () -> Unit, - onSync: () -> Unit, - onLogout: () -> Unit, - onSwitchServer: () -> Unit, -) { - ModalBottomSheet(onDismissRequest = onDismiss) { - // Inset for the system navigation bar so the last rows aren't hidden - // behind the Android nav buttons; scroll as a fallback on short screens. - Column( - Modifier.fillMaxWidth() - .verticalScroll(rememberScrollState()) - .navigationBarsPadding() - .padding(bottom = 12.dp), - ) { - Text( - "Calendarr", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) - MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) - MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) - MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) - // Group view switch (personal + each group) — moved here from the - // top bar to free up space. Group *management* stays below. - if (groups.isNotEmpty()) { - Divider(Modifier.padding(vertical = 4.dp)) - MenuSectionLabel(tr("groups.view")) - GroupSwitchRow( - label = tr("group.switch.personal"), - checked = activeGroup == null, - leading = { Icon(Icons.Filled.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, - onClick = { onSwitchGroup(null) }, - ) - groups.forEach { g -> - GroupSwitchRow( - label = g.name, - checked = activeGroup?.id == g.id, - leading = { GroupIcon(g.icon) }, - onClick = { onSwitchGroup(g) }, - ) - } - } - Divider(Modifier.padding(vertical = 4.dp)) - MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups) - Divider(Modifier.padding(vertical = 4.dp)) - MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) - Divider(Modifier.padding(vertical = 4.dp)) - MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout) - MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer) - } - } -} - -@Composable -private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) { - Row( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.width(18.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge) - } -} - -@Composable -private fun MenuSectionLabel(text: String) { - Text( - text, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), - ) -} - -/** A group-view choice (personal or a specific group) with a trailing check. */ -@Composable -private fun GroupSwitchRow( - label: String, - checked: Boolean, - leading: @Composable () -> Unit, - onClick: () -> Unit, -) { - Row( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - leading() - Spacer(Modifier.width(18.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - if (checked) { - Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) - } - } -} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index c5396b6..818339c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -22,6 +22,9 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Divider +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox @@ -68,11 +71,6 @@ fun SettingsScreen( onClose: () -> Unit, onSettingsChanged: (AppSettings) -> Unit, onSettingsSynced: () -> Unit, - onOpenProfile: () -> Unit = {}, - onOpenAccounts: () -> Unit = {}, - onOpenGroups: () -> Unit = {}, - onSwitchServer: () -> Unit = {}, - onLogout: () -> Unit = {}, vm: SettingsViewModel = hiltViewModel(), ) { val initialSettings = LocalAppSettings.current @@ -214,14 +212,6 @@ fun SettingsScreen( Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it }) } Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp)) - - Divider(Modifier.padding(vertical = 16.dp)) - Section(tr("settings.more")) - NavRow(tr("menu.accounts"), onOpenAccounts) - NavRow(tr("groups.title"), onOpenGroups) - NavRow(tr("menu.profile"), onOpenProfile) - NavRow(tr("menu.server"), onSwitchServer) - NavRow(tr("menu.logout"), onLogout, destructive = true) Spacer(Modifier.size(40.dp)) } } @@ -239,47 +229,39 @@ private fun Section(title: String) { Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) } -@Composable -private fun NavRow(label: String, onClick: () -> Unit, destructive: Boolean = false) { - Row( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - label, - style = MaterialTheme.typography.bodyLarge, - color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, - ) - } -} /** Leading per-row sync toggle: highlighted = synced across devices. */ @Composable private fun SyncIcon(on: Boolean, onClick: () -> Unit) { - IconButton(onClick = onClick, modifier = Modifier.size(36.dp)) { + IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) { Icon( Icons.Filled.Refresh, contentDescription = tr("settings.sync_this"), + modifier = Modifier.size(18.dp), tint = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } } -@OptIn(ExperimentalMaterial3Api::class) +// Compact dropdown trigger (a small outlined chip) — much shorter than a full +// OutlinedTextField, so the settings rows aren't inflated. @Composable private fun Dropdown(options: List>, selected: String, onSelect: (String) -> Unit) { var expanded by remember { mutableStateOf(false) } val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: selected - ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.width(170.dp)) { - OutlinedTextField( - value = selectedLabel, - onValueChange = {}, - readOnly = true, - singleLine = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier.menuAnchor().fillMaxWidth(), - ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + Box { + Row( + Modifier + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) + .clickable { expanded = true } + .padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selectedLabel, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { options.forEach { (key, label) -> DropdownMenuItem(text = { Text(label) }, onClick = { onSelect(key); expanded = false }) } @@ -299,7 +281,7 @@ private fun SyncDropdownRow( Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { SyncIcon(synced, onToggleSync) Spacer(Modifier.size(8.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) Dropdown(options, selected, onSelect) } } @@ -313,7 +295,7 @@ private fun DeviceDropdownRow( ) { Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.size(44.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) Dropdown(options, selected, onSelect) } } @@ -323,7 +305,7 @@ private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) - Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { SyncIcon(synced, onToggleSync) Spacer(Modifier.size(8.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) Switch(checked = checked, onCheckedChange = onChange) } } @@ -341,7 +323,7 @@ private fun SyncColorRow( Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { SyncIcon(synced, onToggleSync) Spacer(Modifier.size(8.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.size(12.dp)) Box(