docs: add architecture docs, AGENTS.md, CLAUDE.md, and copy UI components
- Add docs/01-04 covering data model, interfaces, function TODOs, and implementation plan - Add AGENTS.md with project conventions, architecture rules, feature workflow, and git policy - Add CLAUDE.md pointing to AGENTS.md - Copy UI components and utils from info-krl-android (preference widgets, scrollbars, etc.) - Delete obsolete UiModule.kt
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
package dev.achmad.ledgerr.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val uiModule = module {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.achmad.ledgerr.ui.util.clearFocusOnSoftKeyboardHide
|
||||
import dev.achmad.ledgerr.ui.util.runOnEnterKeyPressed
|
||||
import dev.achmad.ledgerr.ui.util.secondaryItemAlpha
|
||||
import dev.achmad.ledgerr.ui.util.showSoftKeyboard
|
||||
|
||||
const val SEARCH_DEBOUNCE_MILLIS = 250L
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppBar(
|
||||
title: String?,
|
||||
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
// Text
|
||||
subtitle: String? = null,
|
||||
// Up button
|
||||
navigateUp: (() -> Unit)? = null,
|
||||
navigationIcon: ImageVector? = null,
|
||||
// Menu
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
// Action mode
|
||||
actionModeCounter: Int = 0,
|
||||
onCancelActionMode: () -> Unit = {},
|
||||
actionModeActions: @Composable RowScope.() -> Unit = {},
|
||||
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
shadowElevation: Dp = 0.dp,
|
||||
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
|
||||
) {
|
||||
val isActionMode by remember(actionModeCounter) {
|
||||
derivedStateOf { actionModeCounter > 0 }
|
||||
}
|
||||
|
||||
AppBar(
|
||||
modifier = modifier,
|
||||
backgroundColor = backgroundColor,
|
||||
windowInsets = windowInsets,
|
||||
titleContent = {
|
||||
if (isActionMode) {
|
||||
AppBarTitle(actionModeCounter.toString())
|
||||
} else {
|
||||
AppBarTitle(title, subtitle = subtitle)
|
||||
}
|
||||
},
|
||||
navigateUp = navigateUp,
|
||||
navigationIcon = navigationIcon,
|
||||
actions = {
|
||||
if (isActionMode) {
|
||||
actionModeActions()
|
||||
} else {
|
||||
actions()
|
||||
}
|
||||
},
|
||||
isActionMode = isActionMode,
|
||||
onCancelActionMode = onCancelActionMode,
|
||||
scrollBehavior = scrollBehavior,
|
||||
shadowElevation = shadowElevation,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppBar(
|
||||
// Title
|
||||
titleContent: @Composable () -> Unit,
|
||||
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
// Up button
|
||||
navigateUp: (() -> Unit)? = null,
|
||||
navigationIcon: ImageVector? = null,
|
||||
// Menu
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
// Action mode
|
||||
isActionMode: Boolean = false,
|
||||
onCancelActionMode: () -> Unit = {},
|
||||
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
shadowElevation: Dp = 0.dp,
|
||||
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
|
||||
) {
|
||||
Surface(
|
||||
shadowElevation = shadowElevation
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (isActionMode) {
|
||||
IconButton(onClick = onCancelActionMode) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
navigateUp?.let {
|
||||
IconButton(onClick = it) {
|
||||
UpIcon(navigationIcon = navigationIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = windowInsets,
|
||||
title = titleContent,
|
||||
actions = actions,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
elevation = if (isActionMode) 8.dp else 0.dp,
|
||||
),
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarTitle(
|
||||
title: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.basicMarquee(
|
||||
repeatDelayMillis = 2_000,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppBarActions(
|
||||
actions: List<AppBar.AppBarAction>,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
actions.filterIsInstance<AppBar.Action>().map {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(it.title)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = it.onClick,
|
||||
enabled = it.enabled,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = it.icon,
|
||||
tint = it.iconTint ?: LocalContentColor.current,
|
||||
contentDescription = it.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
|
||||
if (overflowActions.isNotEmpty()) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
text = "More Options" // TODO copy
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { showMenu = !showMenu },
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MoreVert,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
modifier = Modifier.widthIn(min = 150.dp),
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
overflowActions.map {
|
||||
val enabled = it.enabled
|
||||
DropdownMenuItem(
|
||||
leadingIcon = when {
|
||||
it.icon != null -> {
|
||||
{
|
||||
Icon(
|
||||
imageVector = it.icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) LocalContentColor.current else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
onClick = {
|
||||
it.onClick()
|
||||
showMenu = false
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = it.title,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param searchEnabled Set to false if you don't want to show search action.
|
||||
* @param searchQuery If null, use normal toolbar.
|
||||
* @param placeholderText If null, [MR.strings.action_search_hint] is used.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchToolbar(
|
||||
searchQuery: String?,
|
||||
onChangeSearchQuery: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color? = null,
|
||||
titleContent: @Composable () -> Unit = {},
|
||||
navigateUp: (() -> Unit)? = null,
|
||||
searchEnabled: Boolean = true,
|
||||
placeholderText: String? = null,
|
||||
onSearch: (String) -> Unit = {},
|
||||
onClickCloseSearch: () -> Unit = { onChangeSearchQuery(null) },
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shadowElevation: Dp = 0.dp,
|
||||
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
AppBar(
|
||||
modifier = modifier,
|
||||
windowInsets = windowInsets,
|
||||
backgroundColor = backgroundColor,
|
||||
titleContent = {
|
||||
if (searchQuery == null) return@AppBar titleContent()
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val searchAndClearFocus: () -> Unit = f@{
|
||||
if (searchQuery.isBlank()) return@f
|
||||
onSearch(searchQuery)
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = onChangeSearchQuery,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.runOnEnterKeyPressed(action = searchAndClearFocus)
|
||||
.showSoftKeyboard(remember { searchQuery.isEmpty() })
|
||||
.clearFocusOnSoftKeyboardHide(),
|
||||
textStyle = MaterialTheme.typography.titleMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 18.sp,
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = searchQuery,
|
||||
innerTextField = innerTextField,
|
||||
enabled = true,
|
||||
singleLine = true,
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
placeholder = {
|
||||
Text(
|
||||
modifier = Modifier.secondaryItemAlpha(),
|
||||
text = (placeholderText ?: "Search"),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
},
|
||||
container = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
navigateUp = if (searchQuery == null) navigateUp else onClickCloseSearch,
|
||||
actions = {
|
||||
key("search") {
|
||||
val onClick = { onChangeSearchQuery("") }
|
||||
|
||||
if (!searchEnabled) {
|
||||
// Don't show search action
|
||||
} else if (searchQuery == null) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text("Search") // TODO copy
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Search,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (searchQuery.isNotEmpty()) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text("Reset") // TODO copy
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
focusRequester.requestFocus()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key("actions") { actions() }
|
||||
},
|
||||
isActionMode = false,
|
||||
scrollBehavior = scrollBehavior,
|
||||
shadowElevation = shadowElevation,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
navigationIcon: ImageVector? = null,
|
||||
) {
|
||||
val icon = navigationIcon
|
||||
?: Icons.AutoMirrored.Outlined.ArrowBack
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface AppBar {
|
||||
sealed interface AppBarAction
|
||||
|
||||
data class Action(
|
||||
val title: String,
|
||||
val icon: ImageVector,
|
||||
val iconTint: Color? = null,
|
||||
val onClick: () -> Unit,
|
||||
val enabled: Boolean = true,
|
||||
) : AppBarAction
|
||||
|
||||
data class OverflowAction(
|
||||
val title: String,
|
||||
val icon: ImageVector? = null,
|
||||
val enabled: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
) : AppBarAction
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
||||
/**
|
||||
* A reusable section component for settings screens.
|
||||
*
|
||||
* @param title The title of the section
|
||||
* @param content The content to be displayed inside the section
|
||||
* @param modifier Additional modifiers to apply to the section container
|
||||
*/
|
||||
@Composable
|
||||
fun CardSection(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(
|
||||
alpha = 0.2f
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable permission item that displays a permission with a grant button or check icon.
|
||||
*
|
||||
* @param text The text describing the permission
|
||||
* @param isGranted Whether the permission is granted
|
||||
* @param onRequestPermission Callback when the user clicks to grant permission
|
||||
*/
|
||||
@Composable
|
||||
fun CardSectionItem(
|
||||
text: String,
|
||||
description: String? = null,
|
||||
isGranted: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
description?.let {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { onRequestPermission() },
|
||||
content = {
|
||||
if (isGranted) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(
|
||||
minHeight = ButtonDefaults.MinHeight,
|
||||
),
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = ButtonDefaults.textButtonColors().contentColor
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "GRANT",
|
||||
color = ButtonDefaults.textButtonColors().contentColor,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable toggle item that displays text with a toggle switch.
|
||||
*
|
||||
* @param text The text describing the toggle
|
||||
* @param isChecked Whether the toggle is checked
|
||||
* @param onCheckedChange Callback when the user changes the toggle state
|
||||
*/
|
||||
@Composable
|
||||
fun ToggleItem(
|
||||
text: String,
|
||||
description: String? = null,
|
||||
isChecked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
description?.let {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = isChecked,
|
||||
onCheckedChange = onCheckedChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable button for settings actions.
|
||||
*
|
||||
* @param text The text to display on the button
|
||||
* @param onClick Callback when the button is clicked
|
||||
* @param enabled Whether the button is enabled
|
||||
* @param backgroundColor Optional background color override
|
||||
* @param contentColor Optional content color override
|
||||
*/
|
||||
@Composable
|
||||
fun CardSectionButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
backgroundColor: Color? = null,
|
||||
contentColor: Color? = null
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
colors = if (backgroundColor != null || contentColor != null) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = backgroundColor ?: ButtonDefaults.buttonColors().containerColor,
|
||||
contentColor = contentColor ?: ButtonDefaults.buttonColors().contentColor
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.buttonColors()
|
||||
}
|
||||
) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun <T> SingleSelectFilterChipGroup(
|
||||
options: List<Pair<T, String>>,
|
||||
selectedOption: Pair<T, String>?,
|
||||
onSelectionChanged: (Pair<T, String>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
options.forEach { option ->
|
||||
val isSelected = selectedOption == option
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
onSelectionChanged(option)
|
||||
},
|
||||
label = { Text(option.second) },
|
||||
colors = FilterChipDefaults.filterChipColors(),
|
||||
leadingIcon = if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Done,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(FilterChipDefaults.IconSize)
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun <T> MultiSelectFilterChipGroup(
|
||||
options: List<Pair<T, String>>,
|
||||
onSelectionChanged: (List<Pair<T, String>>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
selectedOptions: List<Pair<T, String>>,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
options.forEach { option ->
|
||||
val isSelected = selectedOptions.contains(option)
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
val newSelection = if (isSelected) {
|
||||
selectedOptions - option
|
||||
} else {
|
||||
selectedOptions + option
|
||||
}
|
||||
onSelectionChanged(newSelection)
|
||||
},
|
||||
label = { Text(option.second) },
|
||||
colors = FilterChipDefaults.filterChipColors().copy(),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = when {
|
||||
isSelected -> Icons.Filled.Done
|
||||
else -> Icons.Filled.Close
|
||||
},
|
||||
tint = when {
|
||||
isSelected -> LocalContentColor.current
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(FilterChipDefaults.IconSize)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun HelpCard(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
footNote: String = "",
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight,
|
||||
)
|
||||
if (footNote.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = footNote,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class LabeledCheckboxData(
|
||||
val label: String,
|
||||
val checked: Boolean,
|
||||
val onCheckedChange: (Boolean) -> Unit,
|
||||
val modifier: Modifier = Modifier,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LabeledCheckbox(
|
||||
data: LabeledCheckboxData,
|
||||
) {
|
||||
LabeledCheckbox(
|
||||
label = data.label,
|
||||
checked = data.checked,
|
||||
onCheckedChange = data.onCheckedChange,
|
||||
modifier = data.modifier,
|
||||
enabled = data.enabled,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LabeledCheckbox(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(
|
||||
role = Role.Checkbox,
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
onCheckedChange(!checked)
|
||||
}
|
||||
},
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LabeledCheckboxGroup(
|
||||
items: List<LabeledCheckboxData>,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(16.dp),
|
||||
title: String? = null,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
title?.let {
|
||||
item {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
items(items) {
|
||||
LabeledCheckbox(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
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.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LabeledRadioButton(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(
|
||||
role = Role.Checkbox,
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
)
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Albert Chang
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* LazyGrid version of scrollbar modifiers
|
||||
* Adapted from LazyList scrollbar implementation
|
||||
*/
|
||||
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.compose.ui.util.fastSumBy
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
|
||||
/**
|
||||
* Draws horizontal scrollbar to a LazyGrid.
|
||||
*
|
||||
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the grid.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.drawHorizontalScrollbar(
|
||||
state: LazyGridState,
|
||||
reverseScrolling: Boolean = false,
|
||||
// The amount of offset the scrollbar position towards the top of the layout
|
||||
positionOffsetPx: Float = 0f,
|
||||
): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx)
|
||||
|
||||
/**
|
||||
* Draws vertical scrollbar to a LazyGrid.
|
||||
*
|
||||
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the grid.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.drawVerticalScrollbar(
|
||||
state: LazyGridState,
|
||||
reverseScrolling: Boolean = false,
|
||||
// The amount of offset the scrollbar position towards the start of the layout
|
||||
positionOffsetPx: Float = 0f,
|
||||
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx)
|
||||
|
||||
@Composable
|
||||
private fun Modifier.drawScrollbar(
|
||||
state: LazyGridState,
|
||||
orientation: Orientation,
|
||||
reverseScrolling: Boolean,
|
||||
positionOffset: Float,
|
||||
): Modifier = drawScrollbar(
|
||||
orientation,
|
||||
reverseScrolling,
|
||||
) { reverseDirection, atEnd, thickness, color, alpha ->
|
||||
val layoutInfo = state.layoutInfo
|
||||
|
||||
// Use the full viewport size without subtracting content padding
|
||||
val viewportSize = if (orientation == Orientation.Horizontal) {
|
||||
layoutInfo.viewportSize.width
|
||||
} else {
|
||||
layoutInfo.viewportSize.height
|
||||
}
|
||||
|
||||
// Calculate content size without padding for scrollbar calculations
|
||||
val contentViewportSize = viewportSize - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding
|
||||
|
||||
val items = layoutInfo.visibleItemsInfo
|
||||
val itemsSize = items.fastSumBy {
|
||||
if (orientation == Orientation.Horizontal) it.size.width else it.size.height
|
||||
}
|
||||
val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > contentViewportSize
|
||||
val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size
|
||||
val totalSize = estimatedItemSize * layoutInfo.totalItemsCount
|
||||
|
||||
// Calculate thumb size based on content viewport, but draw it on the full viewport
|
||||
val thumbSize = (contentViewportSize / totalSize * contentViewportSize).coerceAtMost(contentViewportSize.toFloat())
|
||||
|
||||
val startOffset = if (items.isEmpty()) {
|
||||
0f
|
||||
} else {
|
||||
items
|
||||
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }
|
||||
?.run {
|
||||
val itemOffset = if (orientation == Orientation.Horizontal) {
|
||||
offset.x
|
||||
} else {
|
||||
offset.y
|
||||
}
|
||||
|
||||
// Calculate the scroll position relative to the content
|
||||
val scrollProgress = if (totalSize > 0) {
|
||||
(estimatedItemSize * index - itemOffset) / totalSize
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
// Map the scroll progress to the available scrollbar area
|
||||
val availableScrollArea = contentViewportSize - thumbSize
|
||||
val calculatedOffset = scrollProgress * availableScrollArea
|
||||
|
||||
// Add the content padding to position the scrollbar correctly within the viewport
|
||||
val paddingOffset = if (reverseDirection) {
|
||||
layoutInfo.afterContentPadding
|
||||
} else {
|
||||
layoutInfo.beforeContentPadding
|
||||
}
|
||||
|
||||
(paddingOffset + calculatedOffset).coerceIn(0f, viewportSize - thumbSize)
|
||||
} ?: 0f
|
||||
}
|
||||
|
||||
val drawScrollbar = onDrawScrollbar(
|
||||
orientation, reverseDirection, atEnd, showScrollbar,
|
||||
thickness, color, alpha, thumbSize, startOffset, positionOffset,
|
||||
)
|
||||
drawContent()
|
||||
drawScrollbar()
|
||||
}
|
||||
|
||||
private fun ContentDrawScope.onDrawScrollbar(
|
||||
orientation: Orientation,
|
||||
reverseDirection: Boolean,
|
||||
atEnd: Boolean,
|
||||
showScrollbar: Boolean,
|
||||
thickness: Float,
|
||||
color: Color,
|
||||
alpha: () -> Float,
|
||||
thumbSize: Float,
|
||||
scrollOffset: Float,
|
||||
positionOffset: Float,
|
||||
): DrawScope.() -> Unit {
|
||||
val topLeft = if (orientation == Orientation.Horizontal) {
|
||||
Offset(
|
||||
if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset,
|
||||
if (atEnd) size.height - positionOffset - thickness else positionOffset,
|
||||
)
|
||||
} else {
|
||||
Offset(
|
||||
if (atEnd) size.width - positionOffset - thickness else positionOffset,
|
||||
if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset,
|
||||
)
|
||||
}
|
||||
val size = if (orientation == Orientation.Horizontal) {
|
||||
Size(thumbSize, thickness)
|
||||
} else {
|
||||
Size(thickness, thumbSize)
|
||||
}
|
||||
|
||||
return {
|
||||
if (showScrollbar) {
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = topLeft,
|
||||
size = size,
|
||||
alpha = alpha(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun Modifier.drawScrollbar(
|
||||
orientation: Orientation,
|
||||
reverseScrolling: Boolean,
|
||||
onDraw: ContentDrawScope.(
|
||||
reverseDirection: Boolean,
|
||||
atEnd: Boolean,
|
||||
thickness: Float,
|
||||
color: Color,
|
||||
alpha: () -> Float,
|
||||
) -> Unit,
|
||||
): Modifier {
|
||||
val scrolled = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
}
|
||||
val nestedScrollConnection = remember(orientation, scrolled) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y
|
||||
if (delta != 0f) scrolled.tryEmit(Unit)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val alpha = remember { Animatable(0f) }
|
||||
LaunchedEffect(scrolled, alpha) {
|
||||
scrolled
|
||||
.sample(100)
|
||||
.collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||
val reverseDirection = if (orientation == Orientation.Horizontal) {
|
||||
if (isLtr) reverseScrolling else !reverseScrolling
|
||||
} else {
|
||||
reverseScrolling
|
||||
}
|
||||
val atEnd = if (orientation == Orientation.Vertical) isLtr else true
|
||||
|
||||
val context = LocalContext.current
|
||||
val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() }
|
||||
val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f)
|
||||
|
||||
return this
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.drawWithContent {
|
||||
onDraw(reverseDirection, atEnd, thickness, color, alpha::value)
|
||||
}
|
||||
}
|
||||
|
||||
private val FadeOutAnimationSpec = tween<Float>(
|
||||
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||
delayMillis = ViewConfiguration.getScrollDefaultDelay(),
|
||||
)
|
||||
|
||||
@Preview(widthDp = 400, heightDp = 400, showBackground = true)
|
||||
@Composable
|
||||
fun LazyGridScrollbarPreview() {
|
||||
val state = rememberLazyGridState()
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.drawVerticalScrollbar(state),
|
||||
state = state,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), // Test with content padding
|
||||
) {
|
||||
items(50) {
|
||||
Text(
|
||||
text = "Item ${it + 1}",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Albert Chang
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Code taken from https://gist.github.com/mxalbert1996/33a360fcab2105a31e5355af98216f5a
|
||||
* with some modifications to handle contentPadding.
|
||||
*
|
||||
* Modifiers for regular scrollable list is omitted.
|
||||
*/
|
||||
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import androidx.compose.ui.util.fastSumBy
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
|
||||
const val STICKY_HEADER_KEY_PREFIX = "sticky:"
|
||||
|
||||
/**
|
||||
* Draws horizontal scrollbar to a LazyList.
|
||||
*
|
||||
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.drawHorizontalScrollbar(
|
||||
state: LazyListState,
|
||||
reverseScrolling: Boolean = false,
|
||||
// The amount of offset the scrollbar position towards the top of the layout
|
||||
positionOffsetPx: Float = 0f,
|
||||
): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx)
|
||||
|
||||
/**
|
||||
* Draws vertical scrollbar to a LazyList.
|
||||
*
|
||||
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.drawVerticalScrollbar(
|
||||
state: LazyListState,
|
||||
reverseScrolling: Boolean = false,
|
||||
// The amount of offset the scrollbar position towards the start of the layout
|
||||
positionOffsetPx: Float = 0f,
|
||||
): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx)
|
||||
|
||||
@Composable
|
||||
private fun Modifier.drawScrollbar(
|
||||
state: LazyListState,
|
||||
orientation: Orientation,
|
||||
reverseScrolling: Boolean,
|
||||
positionOffset: Float,
|
||||
): Modifier = drawScrollbar(
|
||||
orientation,
|
||||
reverseScrolling,
|
||||
) { reverseDirection, atEnd, thickness, color, alpha ->
|
||||
val layoutInfo = state.layoutInfo
|
||||
val viewportSize = if (orientation == Orientation.Horizontal) {
|
||||
layoutInfo.viewportSize.width
|
||||
} else {
|
||||
layoutInfo.viewportSize.height
|
||||
} - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding
|
||||
val items = layoutInfo.visibleItemsInfo
|
||||
val itemsSize = items.fastSumBy { it.size }
|
||||
val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize
|
||||
val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size
|
||||
val totalSize = estimatedItemSize * layoutInfo.totalItemsCount
|
||||
val thumbSize = viewportSize / totalSize * viewportSize
|
||||
val startOffset = if (items.isEmpty()) {
|
||||
0f
|
||||
} else {
|
||||
items
|
||||
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }
|
||||
?.run {
|
||||
val startPadding = if (reverseDirection) {
|
||||
layoutInfo.afterContentPadding
|
||||
} else {
|
||||
layoutInfo.beforeContentPadding
|
||||
}
|
||||
startPadding + ((estimatedItemSize * index - offset) / totalSize * viewportSize)
|
||||
} ?: 0f
|
||||
}
|
||||
val drawScrollbar = onDrawScrollbar(
|
||||
orientation, reverseDirection, atEnd, showScrollbar,
|
||||
thickness, color, alpha, thumbSize, startOffset, positionOffset,
|
||||
)
|
||||
drawContent()
|
||||
drawScrollbar()
|
||||
}
|
||||
|
||||
private fun ContentDrawScope.onDrawScrollbar(
|
||||
orientation: Orientation,
|
||||
reverseDirection: Boolean,
|
||||
atEnd: Boolean,
|
||||
showScrollbar: Boolean,
|
||||
thickness: Float,
|
||||
color: Color,
|
||||
alpha: () -> Float,
|
||||
thumbSize: Float,
|
||||
scrollOffset: Float,
|
||||
positionOffset: Float,
|
||||
): DrawScope.() -> Unit {
|
||||
val topLeft = if (orientation == Orientation.Horizontal) {
|
||||
Offset(
|
||||
if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset,
|
||||
if (atEnd) size.height - positionOffset - thickness else positionOffset,
|
||||
)
|
||||
} else {
|
||||
Offset(
|
||||
if (atEnd) size.width - positionOffset - thickness else positionOffset,
|
||||
if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset,
|
||||
)
|
||||
}
|
||||
val size = if (orientation == Orientation.Horizontal) {
|
||||
Size(thumbSize, thickness)
|
||||
} else {
|
||||
Size(thickness, thumbSize)
|
||||
}
|
||||
|
||||
return {
|
||||
if (showScrollbar) {
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = topLeft,
|
||||
size = size,
|
||||
alpha = alpha(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun Modifier.drawScrollbar(
|
||||
orientation: Orientation,
|
||||
reverseScrolling: Boolean,
|
||||
onDraw: ContentDrawScope.(
|
||||
reverseDirection: Boolean,
|
||||
atEnd: Boolean,
|
||||
thickness: Float,
|
||||
color: Color,
|
||||
alpha: () -> Float,
|
||||
) -> Unit,
|
||||
): Modifier {
|
||||
val scrolled = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
}
|
||||
val nestedScrollConnection = remember(orientation, scrolled) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y
|
||||
if (delta != 0f) scrolled.tryEmit(Unit)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val alpha = remember { Animatable(0f) }
|
||||
LaunchedEffect(scrolled, alpha) {
|
||||
scrolled
|
||||
.sample(100)
|
||||
.collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||
val reverseDirection = if (orientation == Orientation.Horizontal) {
|
||||
if (isLtr) reverseScrolling else !reverseScrolling
|
||||
} else {
|
||||
reverseScrolling
|
||||
}
|
||||
val atEnd = if (orientation == Orientation.Vertical) isLtr else true
|
||||
|
||||
val context = LocalContext.current
|
||||
val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() }
|
||||
val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f)
|
||||
|
||||
return this
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.drawWithContent {
|
||||
onDraw(reverseDirection, atEnd, thickness, color, alpha::value)
|
||||
}
|
||||
}
|
||||
|
||||
private val FadeOutAnimationSpec = tween<Float>(
|
||||
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||
delayMillis = ViewConfiguration.getScrollDefaultDelay(),
|
||||
)
|
||||
|
||||
@Preview(widthDp = 400, heightDp = 400, showBackground = true)
|
||||
@Composable
|
||||
fun LazyListScrollbarPreview() {
|
||||
val state = rememberLazyListState()
|
||||
LazyColumn(
|
||||
modifier = Modifier.drawVerticalScrollbar(state),
|
||||
state = state,
|
||||
) {
|
||||
items(50) {
|
||||
Text(
|
||||
text = "Item ${it + 1}",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 400, showBackground = true)
|
||||
@Composable
|
||||
fun LazyListHorizontalScrollbarPreview() {
|
||||
val state = rememberLazyListState()
|
||||
LazyRow(
|
||||
modifier = Modifier.drawHorizontalScrollbar(state),
|
||||
state = state,
|
||||
) {
|
||||
items(50) {
|
||||
Text(
|
||||
text = (it + 1).toString(),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LinkIcon(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
IconButton(
|
||||
modifier = modifier.padding(4.dp),
|
||||
onClick = { uriHandler.openUri(url) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = label,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Pill(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.padding(start = 4.dp),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(6.dp, 1.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Pill(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize: TextUnit = LocalTextStyle.current.fontSize,
|
||||
) {
|
||||
Pill(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
style = LocalTextStyle.current.merge(fontSize = fontSize),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
|
||||
abstract class ResultScreen: Screen, Parcelable {
|
||||
var arguments: Bundle = Bundle()
|
||||
|
||||
@Composable
|
||||
final override fun Content() {
|
||||
val currentArguments = remember(arguments) {
|
||||
Bundle(arguments).also {
|
||||
arguments.clear()
|
||||
}
|
||||
}
|
||||
Content(currentArguments)
|
||||
}
|
||||
|
||||
@Composable
|
||||
abstract fun Content(arguments: Bundle)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* LazyColumn with scrollbar.
|
||||
*/
|
||||
@Composable
|
||||
fun ScrollbarLazyColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
userScrollEnabled: Boolean = true,
|
||||
positionOffset: Float? = null,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val direction = LocalLayoutDirection.current
|
||||
val density = LocalDensity.current
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.drawVerticalScrollbar(
|
||||
state = state,
|
||||
reverseScrolling = reverseLayout,
|
||||
positionOffsetPx = positionOffset ?: remember(contentPadding) {
|
||||
with(density) { contentPadding.calculateEndPadding(direction).toPx() }
|
||||
},
|
||||
),
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ScrollbarLazyVerticalGrid(
|
||||
columns: GridCells,
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyGridState = rememberLazyGridState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
userScrollEnabled: Boolean = true,
|
||||
positionOffset: Float? = null,
|
||||
content: LazyGridScope.() -> Unit,
|
||||
) {
|
||||
val direction = LocalLayoutDirection.current
|
||||
val density = LocalDensity.current
|
||||
LazyVerticalGrid(
|
||||
columns = columns,
|
||||
modifier = modifier
|
||||
.drawVerticalScrollbar(
|
||||
state = state,
|
||||
reverseScrolling = reverseLayout,
|
||||
positionOffsetPx = positionOffset ?: remember(contentPadding) {
|
||||
with(density) { contentPadding.calculateEndPadding(direction).toPx() }
|
||||
},
|
||||
),
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScrollbarLazyHorizontalGrid(
|
||||
rows: GridCells,
|
||||
modifier: Modifier = Modifier,
|
||||
state: LazyGridState = rememberLazyGridState(),
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
reverseLayout: Boolean = false,
|
||||
verticalArrangement: Arrangement.Vertical =
|
||||
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
userScrollEnabled: Boolean = true,
|
||||
content: LazyGridScope.() -> Unit,
|
||||
) {
|
||||
val direction = LocalLayoutDirection.current
|
||||
val density = LocalDensity.current
|
||||
val positionOffset = remember(contentPadding) {
|
||||
with(density) { contentPadding.calculateEndPadding(direction).toPx() }
|
||||
}
|
||||
LazyHorizontalGrid(
|
||||
rows = rows,
|
||||
modifier = modifier
|
||||
.drawHorizontalScrollbar(
|
||||
state = state,
|
||||
reverseScrolling = reverseLayout,
|
||||
positionOffsetPx = positionOffset
|
||||
),
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = reverseLayout,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
userScrollEnabled = userScrollEnabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
/*
|
||||
* Copyright 2025 Kyriakos Georgiopoulos
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import androidx.compose.animation.core.animateRectAsState
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
class SpotlightRegistry {
|
||||
private val _targets = mutableStateMapOf<String, Rect>()
|
||||
val targets: Map<String, Rect> get() = _targets
|
||||
|
||||
fun update(id: String, rect: Rect) {
|
||||
_targets[id] = rect
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.spotlightTarget(
|
||||
id: String,
|
||||
registry: SpotlightRegistry,
|
||||
extraPadding: Dp = 12.dp
|
||||
): Modifier {
|
||||
val density = LocalDensity.current
|
||||
return this.then(
|
||||
Modifier.onGloballyPositioned { coords ->
|
||||
val extraPaddingPx = with(density) { extraPadding.toPx() }
|
||||
|
||||
val bounds = coords.boundsInWindow()
|
||||
val rect = Rect(
|
||||
offset = bounds.topLeft - Offset(extraPaddingPx, extraPaddingPx),
|
||||
size = Size(bounds.width + 2 * extraPaddingPx, bounds.height + 2 * extraPaddingPx)
|
||||
)
|
||||
registry.update(id, rect)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class SpotlightController {
|
||||
var current by mutableStateOf<Rect?>(null)
|
||||
private set
|
||||
|
||||
fun highlight(rect: Rect?) {
|
||||
current = rect
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpotlightOverlay(
|
||||
controller: SpotlightController,
|
||||
registry: SpotlightRegistry,
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
dimColor: Color = Color.Black.copy(alpha = 0.65f),
|
||||
cornerRadius: Dp = 16.dp
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
val cornerPx = with(LocalDensity.current) { cornerRadius.toPx() }
|
||||
|
||||
var overlayCoords by remember {
|
||||
mutableStateOf<androidx.compose.ui.layout.LayoutCoordinates?>(
|
||||
null
|
||||
)
|
||||
}
|
||||
val targetWindow = controller.current
|
||||
val targetLocal: Rect? = remember(targetWindow, overlayCoords) {
|
||||
targetWindow?.let { r ->
|
||||
overlayCoords?.let { coords ->
|
||||
val tl = coords.windowToLocal(r.topLeft)
|
||||
Rect(tl, r.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val animated by animateRectAsState(targetValue = targetLocal ?: Rect.Zero, label = "spotRect")
|
||||
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (targetLocal != null) 1f else 1f, label = "overlayAlpha"
|
||||
)
|
||||
|
||||
fun hitTest(posWin: Offset): Rect? =
|
||||
registry.targets.values
|
||||
.filter { it.contains(posWin) }
|
||||
.minByOrNull { it.width * it.height }
|
||||
|
||||
val rPx = with(LocalDensity.current) { 96.dp.toPx() }
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { overlayCoords = it }
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen, alpha = alpha)
|
||||
.pointerInput(registry, rPx) {
|
||||
detectTapGestures { posLocal ->
|
||||
val posWin = overlayCoords?.localToWindow(posLocal) ?: posLocal
|
||||
val hit = hitTest(posWin)
|
||||
val fallback =
|
||||
Rect(offset = posWin - Offset(rPx, rPx), size = Size(rPx * 2, rPx * 2))
|
||||
controller.highlight(hit ?: fallback)
|
||||
}
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(dimColor)
|
||||
|
||||
if (targetLocal != null && animated.width > 1f && animated.height > 1f) {
|
||||
val glowRadius = animated.maxDimension * 0.75f
|
||||
if (glowRadius > 1f) {
|
||||
drawRoundRect(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color.White.copy(alpha = 0.15f), Color.Transparent),
|
||||
center = animated.center,
|
||||
radius = glowRadius
|
||||
),
|
||||
topLeft = animated.topLeft,
|
||||
size = animated.size,
|
||||
cornerRadius = CornerRadius(cornerPx, cornerPx)
|
||||
)
|
||||
}
|
||||
drawRoundRect(
|
||||
color = Color.Transparent,
|
||||
topLeft = animated.topLeft,
|
||||
size = animated.size,
|
||||
cornerRadius = CornerRadius(cornerPx, cornerPx),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpotlightDemoScreen(onFinish: () -> Unit = {}) {
|
||||
val registry = remember { SpotlightRegistry() }
|
||||
val controller = remember { SpotlightController() }
|
||||
var overlayVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text("Walkthrough", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"Tap any highlighted element to focus it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
overlayVisible = !overlayVisible
|
||||
|
||||
if (!overlayVisible) {
|
||||
controller.clear()
|
||||
} else {
|
||||
controller.highlight(registry.targets["feature_security_cta"])
|
||||
}
|
||||
},
|
||||
text = { Text(if (overlayVisible) "Got it" else "Show again") },
|
||||
icon = { Icon(Icons.Default.Check, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.spotlightTarget("search", registry)
|
||||
) {
|
||||
Text(
|
||||
"Search settings, features…", Modifier.padding(14.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
AssistChip("Profile", "Edit", id = "qa_profile", registry)
|
||||
AssistChip("Backup", "Sync", id = "qa_backup", registry)
|
||||
AssistChip("Theme", "Dark", id = "qa_theme", registry)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
FeatureCard(
|
||||
title = "Analytics",
|
||||
subtitle = "Understand your activity at a glance.",
|
||||
primaryText = "Open",
|
||||
idCard = "feature_analytics",
|
||||
idCta = "feature_analytics_cta",
|
||||
registry = registry
|
||||
)
|
||||
FeatureCard(
|
||||
title = "Security",
|
||||
subtitle = "Manage passkeys and 2FA.",
|
||||
primaryText = "Manage",
|
||||
idCard = "feature_security",
|
||||
idCta = "feature_security_cta",
|
||||
registry = registry,
|
||||
outlined = true
|
||||
)
|
||||
}
|
||||
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.spotlightTarget("settings_card", registry),
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Notifications") },
|
||||
supportingContent = { Text("Control alerts and badges") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
modifier = Modifier.spotlightTarget("notif_switch", registry)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.spotlightTarget("notif_row", registry)
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Privacy") },
|
||||
supportingContent = { Text("Permissions, data controls") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.spotlightTarget("privacy_row", registry)
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Downloads") },
|
||||
supportingContent = { Text("Storage and offline access") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.spotlightTarget("downloads_row", registry)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Recent activity", style = MaterialTheme.typography.titleMedium)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ActivityRow("Exported data report", "2 min ago", "activity_1", registry)
|
||||
ActivityRow("Passkey added", "Yesterday", "activity_2", registry)
|
||||
ActivityRow("Theme changed to Dark", "2 days ago", "activity_3", registry)
|
||||
}
|
||||
}
|
||||
|
||||
SpotlightOverlay(
|
||||
controller = controller,
|
||||
registry = registry,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
visible = overlayVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RowScope.AssistChip(title: String, action: String, id: String, registry: SpotlightRegistry) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.spotlightTarget(id, registry),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
action, style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun RowScope.FeatureCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
primaryText: String,
|
||||
idCard: String,
|
||||
idCta: String,
|
||||
registry: SpotlightRegistry,
|
||||
outlined: Boolean = false
|
||||
) {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val modifier = Modifier
|
||||
.weight(1f)
|
||||
.spotlightTarget(idCard, registry)
|
||||
|
||||
if (outlined) {
|
||||
OutlinedCard(modifier = modifier, shape = shape) {
|
||||
FeatureCardBody(title, subtitle, primaryText, idCta, registry)
|
||||
}
|
||||
} else {
|
||||
ElevatedCard(modifier = modifier, shape = shape) {
|
||||
FeatureCardBody(title, subtitle, primaryText, idCta, registry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun FeatureCardBody(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
primaryText: String,
|
||||
idCta: String,
|
||||
registry: SpotlightRegistry
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
subtitle, style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = {},
|
||||
modifier = Modifier.spotlightTarget(idCta, registry)
|
||||
) { Text(primaryText) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityRow(title: String, time: String, id: String, registry: SpotlightRegistry) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.spotlightTarget(id, registry),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Row(Modifier.padding(14.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
time, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dev.achmad.ledgerr.ui.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun TabText(text: String, badgeCount: Int? = null) {
|
||||
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (badgeCount != null) {
|
||||
Pill(
|
||||
text = "$badgeCount",
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import dev.achmad.ledgerr.ui.util.MultiplePermissionsState
|
||||
import dev.achmad.ledgerr.ui.util.PermissionState
|
||||
import dev.achmad.ledgerr.core.preference.Preference as PreferenceData
|
||||
|
||||
sealed class Preference {
|
||||
abstract val title: String
|
||||
abstract val visible: Boolean
|
||||
abstract val enabled: Boolean
|
||||
|
||||
sealed class PreferenceItem<T> : Preference() {
|
||||
abstract val subtitle: CharSequence?
|
||||
abstract val icon: ImageVector?
|
||||
abstract val onValueChanged: suspend (value: T) -> Boolean
|
||||
|
||||
/**
|
||||
* A basic [PreferenceItem] that only displays texts.
|
||||
*/
|
||||
data class TextPreference(
|
||||
override val title: String,
|
||||
override val subtitle: CharSequence? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val icon: ImageVector? = null,
|
||||
val titleColor: Color = Color.Unspecified,
|
||||
val subtitleColor: Color = Color.Unspecified,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
) : PreferenceItem<String>() {
|
||||
override val onValueChanged: suspend (value: String) -> Boolean = { true }
|
||||
}
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that provides a two-state toggleable option.
|
||||
*/
|
||||
data class SwitchPreference(
|
||||
val preference: PreferenceData<Boolean>,
|
||||
override val title: String,
|
||||
override val subtitle: CharSequence? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val icon: ImageVector? = null,
|
||||
override val onValueChanged: suspend (value: Boolean) -> Boolean = { true },
|
||||
) : PreferenceItem<Boolean>()
|
||||
|
||||
data class BasicSwitchPreference(
|
||||
val value: Boolean,
|
||||
override val title: String,
|
||||
override val subtitle: CharSequence? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (value: Boolean) -> Boolean = { true },
|
||||
) : PreferenceItem<Boolean>() {
|
||||
override val icon: ImageVector? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that displays a list of entries as a dialog.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
data class ListPreference<T>(
|
||||
val preference: PreferenceData<T>,
|
||||
val entries: Map<T, String>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (value: T, entries: Map<T, String>) -> String? =
|
||||
{ v, e -> subtitle?.format(e[v]) },
|
||||
override val icon: ImageVector? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (value: T) -> Boolean = { true },
|
||||
) : PreferenceItem<T>() {
|
||||
internal fun internalSet(value: Any) = preference.set(value as T)
|
||||
internal suspend fun internalOnValueChanged(value: Any) = onValueChanged(value as T)
|
||||
|
||||
@Composable
|
||||
internal fun internalSubtitleProvider(value: Any?, entries: Map<out Any?, String>) =
|
||||
subtitleProvider(value as T, entries as Map<T, String>)
|
||||
}
|
||||
|
||||
/**
|
||||
* [ListPreference] but with no connection to a [PreferenceData]
|
||||
*/
|
||||
data class BasicListPreference(
|
||||
val value: String,
|
||||
val entries: Map<String, String>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (value: String, entries: Map<String, String>) -> String? =
|
||||
{ v, e -> subtitle?.format(e[v]) },
|
||||
override val icon: ImageVector? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (value: String) -> Boolean = { true },
|
||||
) : PreferenceItem<String>()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
data class ListSearchPreference<T>(
|
||||
val preference: PreferenceData<T>,
|
||||
val entries: () -> Map<T, String>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (value: T, entries: Map<T, String>) -> String? =
|
||||
{ v, e -> subtitle?.format(e[v]) },
|
||||
override val icon: ImageVector? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (value: T) -> Boolean = { true },
|
||||
) : PreferenceItem<T>() {
|
||||
internal fun internalSet(value: Any) = preference.set(value as T)
|
||||
internal suspend fun internalOnValueChanged(value: Any) = onValueChanged(value as T)
|
||||
|
||||
@Composable
|
||||
internal fun internalSubtitleProvider(value: Any?, entries: Map<out Any?, String>) =
|
||||
subtitleProvider(value as T, entries as Map<T, String>)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that displays a list of entries as a dialog.
|
||||
* Multiple entries can be selected at the same time.
|
||||
*/
|
||||
data class MultiSelectListPreference(
|
||||
val preference: PreferenceData<Set<String>>,
|
||||
val entries: Map<String, String>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (value: Set<String>, entries: Map<String, String>) -> String? =
|
||||
{ v, e ->
|
||||
val combined = remember(v, e) {
|
||||
v.mapNotNull { e[it] }
|
||||
.joinToString()
|
||||
.takeUnless { it.isBlank() }
|
||||
}
|
||||
?: "None" // TODO copy
|
||||
subtitle?.format(combined)
|
||||
},
|
||||
override val icon: ImageVector? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (value: Set<String>) -> Boolean = { true },
|
||||
) : PreferenceItem<Set<String>>()
|
||||
|
||||
data class BasicMultiSelectListPreference(
|
||||
val values: List<String>,
|
||||
val entries: Map<String, String>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (value: List<String>, entries: Map<String, String>) -> String? =
|
||||
{ v, e ->
|
||||
val combined = remember(v, e) {
|
||||
v.mapNotNull { e[it] }
|
||||
.joinToString()
|
||||
.takeUnless { it.isBlank() }
|
||||
}
|
||||
?: "None" // TODO copy
|
||||
subtitle?.format(combined)
|
||||
},
|
||||
override val icon: ImageVector? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (value: List<String>) -> Boolean = { true },
|
||||
): PreferenceItem<List<String>>()
|
||||
|
||||
data class AlertDialogPreference(
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val icon: ImageVector? = null,
|
||||
val titleColor: Color = Color.Unspecified,
|
||||
val subtitleColor: Color = Color.Unspecified,
|
||||
val dialogTitle: String,
|
||||
val dialogText: String,
|
||||
val onConfirm: () -> Unit,
|
||||
val onCancel: () -> Unit = {},
|
||||
) : PreferenceItem<String>() {
|
||||
override val onValueChanged: suspend (value: String) -> Boolean = { true }
|
||||
}
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that shows a EditText in the dialog.
|
||||
*/
|
||||
data class EditTextPreference(
|
||||
val preference: PreferenceData<String>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (value: String) -> Boolean = { true },
|
||||
) : PreferenceItem<String>() {
|
||||
override val icon: ImageVector? = null
|
||||
}
|
||||
|
||||
data class InfoPreference(
|
||||
override val title: String,
|
||||
) : PreferenceItem<String>() {
|
||||
override val visible: Boolean = true
|
||||
override val enabled: Boolean = true
|
||||
override val subtitle: String? = null
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (value: String) -> Boolean = { true }
|
||||
}
|
||||
|
||||
data class MultiplePermissionPreference(
|
||||
val permissionState: MultiplePermissionsState,
|
||||
override val title: String,
|
||||
override val subtitle: String? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
): PreferenceItem<Unit>() {
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (value: Unit) -> Boolean = { true }
|
||||
}
|
||||
|
||||
data class PermissionPreference(
|
||||
val permissionState: PermissionState,
|
||||
override val title: String,
|
||||
override val subtitle: String? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
): PreferenceItem<Unit>() {
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (value: Unit) -> Boolean = { true }
|
||||
}
|
||||
|
||||
data class CheckPreference(
|
||||
override val title: String,
|
||||
override val subtitle: CharSequence? = null,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
val value: String,
|
||||
val checked: Boolean,
|
||||
val onClick: (String) -> Unit,
|
||||
val titleColor: Color = Color.Unspecified,
|
||||
val subtitleColor: Color = Color.Unspecified,
|
||||
): PreferenceItem<Unit>() {
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (value: Unit) -> Boolean = { true }
|
||||
}
|
||||
|
||||
data class CustomPreference(
|
||||
val content: @Composable () -> Unit,
|
||||
) : PreferenceItem<Unit>() {
|
||||
override val title: String = ""
|
||||
override val visible: Boolean = true
|
||||
override val enabled: Boolean = true
|
||||
override val subtitle: String? = null
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (value: Unit) -> Boolean = { true }
|
||||
}
|
||||
}
|
||||
|
||||
data class PreferenceGroup(
|
||||
override val title: String,
|
||||
override val visible: Boolean = true,
|
||||
override val enabled: Boolean = true,
|
||||
|
||||
val preferenceItems: List<PreferenceItem<out Any>>,
|
||||
) : Preference()
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.structuralEqualityPolicy
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.AlertDialogPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.BasicMultiSelectListPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.CheckPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.EditTextPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.InfoWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.ListPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.ListSearchPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.MultiSelectListPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.PermissionPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.SwitchPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.TextPreferenceWidget
|
||||
import dev.achmad.ledgerr.ui.util.collectAsState
|
||||
import dev.achmad.ledgerr.ui.util.onClickInput
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false }
|
||||
val LocalPreferenceMinHeight = compositionLocalOf(structuralEqualityPolicy()) { 56.dp }
|
||||
|
||||
@Composable
|
||||
fun StatusWrapper(
|
||||
item: Preference.PreferenceItem<*>,
|
||||
highlightKey: String?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val visible = item.visible
|
||||
val enabled = item.enabled
|
||||
val highlighted = item.title == highlightKey
|
||||
AnimatedVisibility(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!enabled) {
|
||||
Modifier.onClickInput(
|
||||
pass = PointerEventPass.Initial,
|
||||
ripple = false,
|
||||
onUp = {
|
||||
// do nothing
|
||||
}
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
visible = visible,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
content = {
|
||||
CompositionLocalProvider(
|
||||
LocalPreferenceHighlighted provides highlighted,
|
||||
LocalContentColor provides when {
|
||||
!enabled -> LocalContentColor.current.copy(alpha = .38f)
|
||||
else -> LocalContentColor.current
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PreferenceItem(
|
||||
item: Preference.PreferenceItem<*>,
|
||||
highlightKey: String?,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
StatusWrapper(
|
||||
item = item,
|
||||
highlightKey = highlightKey,
|
||||
) {
|
||||
when (item) {
|
||||
is Preference.PreferenceItem.SwitchPreference -> {
|
||||
val value by item.preference.collectAsState()
|
||||
SwitchPreferenceWidget(
|
||||
enabled = item.enabled,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
icon = item.icon,
|
||||
checked = value,
|
||||
onCheckedChanged = { newValue ->
|
||||
scope.launch {
|
||||
if (item.onValueChanged(newValue)) {
|
||||
item.preference.set(newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.BasicSwitchPreference -> {
|
||||
SwitchPreferenceWidget(
|
||||
enabled = item.enabled,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
icon = item.icon,
|
||||
checked = item.value,
|
||||
onCheckedChanged = { newValue ->
|
||||
scope.launch { item.onValueChanged(newValue) }
|
||||
}
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.ListPreference<*> -> {
|
||||
val value by item.preference.collectAsState()
|
||||
ListPreferenceWidget(
|
||||
value = value,
|
||||
enabled = item.enabled,
|
||||
title = item.title,
|
||||
subtitle = item.internalSubtitleProvider(value, item.entries),
|
||||
icon = item.icon,
|
||||
entries = item.entries,
|
||||
onValueChange = { newValue ->
|
||||
scope.launch {
|
||||
if (item.internalOnValueChanged(newValue!!)) {
|
||||
item.internalSet(newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.BasicListPreference -> {
|
||||
ListPreferenceWidget(
|
||||
value = item.value,
|
||||
enabled = item.enabled,
|
||||
title = item.title,
|
||||
subtitle = item.subtitleProvider(item.value, item.entries),
|
||||
icon = item.icon,
|
||||
entries = item.entries,
|
||||
onValueChange = { scope.launch { item.onValueChanged(it) } },
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.ListSearchPreference -> {
|
||||
val value by item.preference.collectAsState()
|
||||
ListSearchPreferenceWidget(
|
||||
value = value,
|
||||
enabled = item.enabled,
|
||||
title = item.title,
|
||||
subtitle = item.internalSubtitleProvider(value, item.entries.invoke()),
|
||||
icon = item.icon,
|
||||
entries = item.entries,
|
||||
onValueChange = { newValue ->
|
||||
scope.launch {
|
||||
if (item.internalOnValueChanged(newValue!!)) {
|
||||
item.internalSet(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.MultiSelectListPreference -> {
|
||||
val values by item.preference.collectAsState()
|
||||
MultiSelectListPreferenceWidget(
|
||||
preference = item,
|
||||
values = values,
|
||||
enabled = item.enabled,
|
||||
onValuesChange = { newValues ->
|
||||
scope.launch {
|
||||
if (item.onValueChanged(newValues)) {
|
||||
item.preference.set(newValues.toMutableSet())
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.BasicMultiSelectListPreference -> {
|
||||
BasicMultiSelectListPreferenceWidget(
|
||||
preference = item,
|
||||
enabled = item.enabled,
|
||||
onValuesChange = { newValues ->
|
||||
scope.launch {
|
||||
item.onValueChanged(newValues)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.AlertDialogPreference -> {
|
||||
AlertDialogPreferenceWidget(
|
||||
enabled = item.enabled,
|
||||
icon = item.icon,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
titleColor = item.titleColor,
|
||||
subtitleColor = item.subtitleColor,
|
||||
dialogTitle = item.dialogTitle,
|
||||
dialogText = item.dialogText,
|
||||
onConfirm = item.onConfirm,
|
||||
onCancel = item.onCancel
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.TextPreference -> {
|
||||
TextPreferenceWidget(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
titleColor = item.titleColor,
|
||||
subtitleColor = item.subtitleColor,
|
||||
icon = item.icon,
|
||||
onPreferenceClick = item.onClick,
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.EditTextPreference -> {
|
||||
val values by item.preference.collectAsState()
|
||||
EditTextPreferenceWidget(
|
||||
enabled = item.enabled,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
icon = item.icon,
|
||||
value = values,
|
||||
onConfirm = {
|
||||
val accepted = item.onValueChanged(it)
|
||||
if (accepted) item.preference.set(it)
|
||||
accepted
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.InfoPreference -> {
|
||||
InfoWidget(text = item.title)
|
||||
}
|
||||
is Preference.PreferenceItem.MultiplePermissionPreference -> {
|
||||
PermissionPreferenceWidget(
|
||||
enabled = item.enabled,
|
||||
isGranted = item.permissionState.isAllPermissionsGranted(),
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
onRequestPermission = {
|
||||
item.permissionState.requestPermissions()
|
||||
}
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.PermissionPreference -> {
|
||||
PermissionPreferenceWidget(
|
||||
enabled = item.enabled,
|
||||
isGranted = item.permissionState.isGranted.value,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
onRequestPermission = {
|
||||
item.permissionState.requestPermission()
|
||||
}
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.CheckPreference -> {
|
||||
CheckPreferenceWidget(
|
||||
value = item.value,
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
checked = item.checked,
|
||||
onClick = item.onClick
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.CustomPreference -> {
|
||||
item.content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import dev.achmad.ledgerr.ui.components.AppBar
|
||||
import dev.achmad.ledgerr.ui.components.ScrollbarLazyColumn
|
||||
import dev.achmad.ledgerr.ui.components.preference.widget.PreferenceGroupHeader
|
||||
import dev.achmad.ledgerr.ui.util.onClickInput
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PreferenceScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
loading: Boolean = false,
|
||||
shadowElevation: Dp = 0.dp,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
onBackPressed: (() -> Unit)? = null,
|
||||
itemsProvider: @Composable () -> List<Preference>,
|
||||
topBarScrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
|
||||
rememberTopAppBarState(),
|
||||
),
|
||||
bottomBar: @Composable () -> Unit = {},
|
||||
) {
|
||||
val items = itemsProvider()
|
||||
val topBar: @Composable () -> Unit = {
|
||||
if (title != null) {
|
||||
Surface(
|
||||
shadowElevation = shadowElevation
|
||||
) {
|
||||
AppBar(
|
||||
title = title,
|
||||
navigateUp = onBackPressed,
|
||||
actions = actions,
|
||||
scrollBehavior = topBarScrollBehavior,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
Scaffold(
|
||||
topBar = topBar,
|
||||
bottomBar = { bottomBar() },
|
||||
content = { contentPadding ->
|
||||
val lazyListState = rememberLazyListState()
|
||||
ScrollbarLazyColumn(
|
||||
modifier = modifier,
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items.fastForEachIndexed { i, preference ->
|
||||
when (preference) {
|
||||
// Create Preference Group
|
||||
is Preference.PreferenceGroup -> {
|
||||
item {
|
||||
Column {
|
||||
PreferenceGroupHeader(
|
||||
title = preference.title,
|
||||
visible = preference.visible
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!preference.visible) return@fastForEachIndexed
|
||||
items(preference.preferenceItems) { item ->
|
||||
PreferenceItem(
|
||||
item = item,
|
||||
highlightKey = null,
|
||||
)
|
||||
}
|
||||
item {
|
||||
if (i < items.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create Preference Item
|
||||
is Preference.PreferenceItem<*> -> item {
|
||||
PreferenceItem(
|
||||
item = preference,
|
||||
highlightKey = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.then(
|
||||
Modifier.onClickInput(
|
||||
pass = PointerEventPass.Initial,
|
||||
ripple = false,
|
||||
onUp = {
|
||||
// do nothing
|
||||
}
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun List<Preference>.findHighlightedIndex(highlightKey: String): Int {
|
||||
return flatMap {
|
||||
if (it is Preference.PreferenceGroup) {
|
||||
buildList<String?> {
|
||||
add(null) // Header
|
||||
addAll(it.preferenceItems.map { groupItem -> groupItem.title })
|
||||
add(null) // Spacer
|
||||
}
|
||||
} else {
|
||||
listOf(it.title)
|
||||
}
|
||||
}.indexOfFirst { it == highlightKey }
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import dev.achmad.ledgerr.R
|
||||
|
||||
@Composable
|
||||
fun AlertDialogPreferenceWidget(
|
||||
enabled: Boolean,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector? = null,
|
||||
titleColor: Color = Color.Unspecified,
|
||||
subtitleColor: Color = Color.Unspecified,
|
||||
dialogTitle: String,
|
||||
dialogText: String,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
titleColor = titleColor,
|
||||
subtitleColor = subtitleColor,
|
||||
icon = icon,
|
||||
onPreferenceClick = { if (enabled) isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
title = { Text(text = dialogTitle) },
|
||||
text = {
|
||||
Text(text = dialogText)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
isDialogShown = false
|
||||
onConfirm()
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
isDialogShown = false
|
||||
onCancel()
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.StartOffset
|
||||
import androidx.compose.animation.core.StartOffsetType
|
||||
import androidx.compose.animation.core.repeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
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.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.achmad.ledgerr.ui.components.preference.LocalPreferenceHighlighted
|
||||
import dev.achmad.ledgerr.ui.components.preference.LocalPreferenceMinHeight
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
internal fun BasePreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
titleColor: Color = Color.Unspecified,
|
||||
subcomponent: @Composable (ColumnScope.() -> Unit)? = null,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
widget: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val highlighted = LocalPreferenceHighlighted.current
|
||||
val minHeight = LocalPreferenceMinHeight.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.highlightBackground(highlighted)
|
||||
.sizeIn(minHeight = minHeight)
|
||||
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Box(
|
||||
modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
|
||||
content = { icon() },
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = PrefsVerticalPadding),
|
||||
) {
|
||||
if (!title.isNullOrBlank()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
|
||||
text = title,
|
||||
color = titleColor,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = TitleFontSize,
|
||||
)
|
||||
}
|
||||
subcomponent?.invoke(this)
|
||||
}
|
||||
if (widget != null) {
|
||||
Box(
|
||||
modifier = Modifier.padding(end = PrefsHorizontalPadding),
|
||||
content = { widget() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier {
|
||||
var highlightFlag by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
if (highlighted) {
|
||||
highlightFlag = true
|
||||
delay(3.seconds)
|
||||
highlightFlag = false
|
||||
}
|
||||
}
|
||||
val highlight by animateColorAsState(
|
||||
targetValue = if (highlightFlag) {
|
||||
MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
animationSpec = if (highlightFlag) {
|
||||
repeatable(
|
||||
iterations = 5,
|
||||
animation = tween(durationMillis = 200),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
initialStartOffset = StartOffset(
|
||||
offsetMillis = 600,
|
||||
offsetType = StartOffsetType.Delay,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
tween(200)
|
||||
},
|
||||
label = "highlight",
|
||||
)
|
||||
return this.background(color = highlight)
|
||||
}
|
||||
|
||||
internal val TrailingWidgetBuffer = 16.dp
|
||||
internal val PrefsHorizontalPadding = 16.dp
|
||||
internal val PrefsVerticalPadding = 16.dp
|
||||
internal val TitleFontSize = 16.sp
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import dev.achmad.ledgerr.ui.components.LabeledCheckbox
|
||||
import dev.achmad.ledgerr.ui.components.ScrollbarLazyColumn
|
||||
import dev.achmad.ledgerr.ui.components.preference.Preference
|
||||
|
||||
@Composable
|
||||
fun BasicMultiSelectListPreferenceWidget(
|
||||
preference: Preference.PreferenceItem.BasicMultiSelectListPreference,
|
||||
enabled: Boolean,
|
||||
onValuesChange: (List<String>) -> Unit,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = preference.title,
|
||||
subtitle = preference.subtitleProvider(preference.values, preference.entries),
|
||||
icon = preference.icon,
|
||||
onPreferenceClick = { if (enabled) isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
val selected = remember {
|
||||
preference.entries.keys
|
||||
.filter { preference.values.contains(it) }
|
||||
.toMutableStateList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
title = { Text(text = preference.title) },
|
||||
text = {
|
||||
Box {
|
||||
val state = rememberLazyListState()
|
||||
ScrollbarLazyColumn(state = state) {
|
||||
preference.entries.forEach { current ->
|
||||
item {
|
||||
val isSelected = selected.contains(current.key)
|
||||
LabeledCheckbox(
|
||||
label = current.value,
|
||||
checked = isSelected,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
selected.add(current.key)
|
||||
} else {
|
||||
selected.remove(current.key)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
|
||||
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onValuesChange(selected)
|
||||
isDialogShown = false
|
||||
},
|
||||
) {
|
||||
Text(text = "OK") // TODO copy
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = "Cancel") // TODO copy
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
fun CheckPreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
subtitle: CharSequence? = null,
|
||||
value: String,
|
||||
icon: ImageVector? = null,
|
||||
checked: Boolean = false,
|
||||
onClick: (String) -> Unit,
|
||||
) {
|
||||
TextPreferenceWidget(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
icon = icon,
|
||||
widget = if (checked) {
|
||||
{
|
||||
Icon(
|
||||
modifier = Modifier.padding(start = TrailingWidgetBuffer),
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
onPreferenceClick = { onClick(value) }
|
||||
)
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun EditTextPreferenceWidget(
|
||||
enabled: Boolean,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector?,
|
||||
value: String,
|
||||
widget: @Composable (() -> Unit)? = null,
|
||||
onConfirm: suspend (String) -> Boolean,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = title,
|
||||
subtitle = subtitle?.format(value),
|
||||
icon = icon,
|
||||
widget = widget,
|
||||
onPreferenceClick = { if (enabled) isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val onDismissRequest = { isDialogShown = false }
|
||||
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(value))
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { textFieldValue = it },
|
||||
trailingIcon = {
|
||||
if (textFieldValue.text.isBlank()) {
|
||||
Icon(imageVector = Icons.Filled.Error, contentDescription = null)
|
||||
} else {
|
||||
IconButton(onClick = { textFieldValue = TextFieldValue("") }) {
|
||||
Icon(imageVector = Icons.Filled.Cancel, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
isError = textFieldValue.text.isBlank(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = textFieldValue.text != value && textFieldValue.text.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (onConfirm(textFieldValue.text)) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = "OK") // TODO copy
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = "Cancel") // TODO copy
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.achmad.ledgerr.ui.util.secondaryItemAlpha
|
||||
|
||||
@Composable
|
||||
internal fun InfoWidget(text: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
horizontal = PrefsHorizontalPadding,
|
||||
vertical = 16.dp,
|
||||
)
|
||||
.secondaryItemAlpha(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.unit.dp
|
||||
import dev.achmad.ledgerr.ui.components.ScrollbarLazyColumn
|
||||
|
||||
@Composable
|
||||
fun <T> ListPreferenceWidget(
|
||||
value: T,
|
||||
enabled: Boolean,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector?,
|
||||
entries: Map<out T, String>,
|
||||
onValueChange: (T) -> Unit,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
icon = icon,
|
||||
onPreferenceClick = { if (enabled) isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Box {
|
||||
val state = rememberLazyListState()
|
||||
ScrollbarLazyColumn(state = state) {
|
||||
entries.forEach { current ->
|
||||
val isSelected = value == current.key
|
||||
item {
|
||||
DialogRow(
|
||||
label = current.value,
|
||||
isSelected = isSelected,
|
||||
onSelected = {
|
||||
onValueChange(current.key!!)
|
||||
isDialogShown = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
|
||||
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = "Cancel") // TODO copy
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogRow(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { if (!isSelected) onSelected() },
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.minimumInteractiveComponentSize(),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge.merge(),
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.achmad.ledgerr.ui.components.ScrollbarLazyColumn
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> ListSearchPreferenceWidget(
|
||||
value: T,
|
||||
enabled: Boolean,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector?,
|
||||
entries: () -> Map<out T, String>,
|
||||
onValueChange: (T) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var searchBarHeight by remember { mutableStateOf(0.dp) }
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
val searchEntries by remember {
|
||||
derivedStateOf {
|
||||
entries().filter {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
it.value.lowercase().contains(searchQuery.lowercase())
|
||||
} else true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
icon = icon,
|
||||
onPreferenceClick = { if (enabled) isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
searchQuery = ""
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Box {
|
||||
val state = rememberLazyListState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged {
|
||||
searchBarHeight = with(density) {
|
||||
it.height.toDp()
|
||||
}
|
||||
},
|
||||
) {
|
||||
SearchBar(
|
||||
modifier = Modifier.offset(y = (-4).dp),
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
onSearch = {},
|
||||
expanded = false,
|
||||
onExpandedChange = {},
|
||||
placeholder = { Text("Search") },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = RectangleShape,
|
||||
expanded = false,
|
||||
onExpandedChange = {},
|
||||
content = {},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (searchEntries.isEmpty()) {
|
||||
Text(
|
||||
text = "No entries found",
|
||||
style = MaterialTheme.typography.bodyLarge.merge(),
|
||||
modifier = Modifier
|
||||
.padding(top = searchBarHeight)
|
||||
.align(Alignment.Center)
|
||||
.padding(vertical = 48.dp),
|
||||
)
|
||||
} else {
|
||||
ScrollbarLazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(top = searchBarHeight)
|
||||
.padding(top = 8.dp),
|
||||
state = state,
|
||||
) {
|
||||
searchEntries.forEach { current ->
|
||||
val isSelected = value == current.key
|
||||
item {
|
||||
DialogRow(
|
||||
label = current.value,
|
||||
isSelected = isSelected,
|
||||
onSelected = {
|
||||
onValueChange(current.key!!)
|
||||
isDialogShown = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
|
||||
if (state.canScrollForward) {
|
||||
HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = "Cancel") // TODO copy
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogRow(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { if (!isSelected) onSelected() },
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.minimumInteractiveComponentSize(),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge.merge(),
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import dev.achmad.ledgerr.ui.components.LabeledCheckbox
|
||||
import dev.achmad.ledgerr.ui.components.preference.Preference
|
||||
|
||||
@Composable
|
||||
fun MultiSelectListPreferenceWidget(
|
||||
preference: Preference.PreferenceItem.MultiSelectListPreference,
|
||||
values: Set<String>,
|
||||
enabled: Boolean,
|
||||
onValuesChange: (Set<String>) -> Unit,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = preference.title,
|
||||
subtitle = preference.subtitleProvider(values, preference.entries),
|
||||
icon = preference.icon,
|
||||
onPreferenceClick = { if (enabled) isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
val selected = remember {
|
||||
preference.entries.keys
|
||||
.filter { values.contains(it) }
|
||||
.toMutableStateList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
title = { Text(text = preference.title) },
|
||||
text = {
|
||||
LazyColumn {
|
||||
preference.entries.forEach { current ->
|
||||
item {
|
||||
val isSelected = selected.contains(current.key)
|
||||
LabeledCheckbox(
|
||||
label = current.value,
|
||||
checked = isSelected,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
selected.add(current.key)
|
||||
} else {
|
||||
selected.remove(current.key)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onValuesChange(selected.toMutableSet())
|
||||
isDialogShown = false
|
||||
},
|
||||
) {
|
||||
Text(text = "OK") // TODO copy
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = "Cancel") // TODO copy
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun PermissionPreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean,
|
||||
isGranted: Boolean = false,
|
||||
title: String? = null,
|
||||
subtitle: CharSequence? = null,
|
||||
onRequestPermission: () -> Unit,
|
||||
) {
|
||||
val buttonContentColor = when {
|
||||
enabled -> ButtonDefaults.textButtonColors().contentColor
|
||||
else -> LocalContentColor.current
|
||||
}
|
||||
TextPreferenceWidget(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
widget = {
|
||||
TextButton(
|
||||
onClick = onRequestPermission
|
||||
) {
|
||||
if (isGranted) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = buttonContentColor
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "GRANT",
|
||||
color = buttonContentColor,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onPreferenceClick = onRequestPermission
|
||||
)
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PreferenceGroupHeader(
|
||||
title: String,
|
||||
visible: Boolean,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
content = {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp, top = 14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
fun SwitchPreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean,
|
||||
title: String,
|
||||
subtitle: CharSequence? = null,
|
||||
icon: ImageVector? = null,
|
||||
checked: Boolean = false,
|
||||
onCheckedChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
TextPreferenceWidget(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
icon = icon,
|
||||
widget = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = null,
|
||||
modifier = Modifier.padding(start = TrailingWidgetBuffer),
|
||||
)
|
||||
},
|
||||
onPreferenceClick = { onCheckedChanged(!checked) },
|
||||
)
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import dev.achmad.ledgerr.ui.util.secondaryItemAlpha
|
||||
|
||||
@Composable
|
||||
fun TextPreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
subtitle: CharSequence? = null,
|
||||
titleColor: Color = Color.Unspecified,
|
||||
subtitleColor: Color = Color.Unspecified,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
widget: @Composable (() -> Unit)? = null,
|
||||
onPreferenceClick: (() -> Unit)? = null,
|
||||
) {
|
||||
BasePreferenceWidget(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
titleColor = titleColor,
|
||||
subcomponent = if (!subtitle.isNullOrBlank()) {
|
||||
{
|
||||
if (subtitle is AnnotatedString) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = PrefsHorizontalPadding)
|
||||
.secondaryItemAlpha(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = subtitleColor,
|
||||
maxLines = 10,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = subtitle.toString(),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = PrefsHorizontalPadding)
|
||||
.secondaryItemAlpha(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = subtitleColor,
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
icon = if (icon != null) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
tint = iconTint,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onPreferenceClick,
|
||||
widget = widget,
|
||||
)
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package dev.achmad.ledgerr.ui.components.preference.widget
|
||||
|
||||
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.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CheckBox
|
||||
import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank
|
||||
import androidx.compose.material.icons.rounded.DisabledByDefault
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.achmad.ledgerr.R
|
||||
|
||||
private enum class State {
|
||||
CHECKED,
|
||||
INVERSED,
|
||||
UNCHECKED,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> TriStateListDialog(
|
||||
title: String,
|
||||
message: String? = null,
|
||||
items: List<T>,
|
||||
initialChecked: List<T>,
|
||||
initialInversed: List<T>,
|
||||
itemLabel: @Composable (T) -> String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onValueChanged: (newIncluded: List<T>, newExcluded: List<T>) -> Unit,
|
||||
) {
|
||||
val selected = remember {
|
||||
items
|
||||
.map {
|
||||
when (it) {
|
||||
in initialChecked -> State.CHECKED
|
||||
in initialInversed -> State.INVERSED
|
||||
else -> State.UNCHECKED
|
||||
}
|
||||
}
|
||||
.toMutableStateList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column {
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(state = listState) {
|
||||
itemsIndexed(items = items) { index, item ->
|
||||
val state = selected[index]
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable {
|
||||
selected[index] = when (state) {
|
||||
State.UNCHECKED -> State.CHECKED
|
||||
State.CHECKED -> State.INVERSED
|
||||
State.INVERSED -> State.UNCHECKED
|
||||
}
|
||||
}
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(end = 20.dp),
|
||||
imageVector = when (state) {
|
||||
State.UNCHECKED -> Icons.Rounded.CheckBoxOutlineBlank
|
||||
State.CHECKED -> Icons.Rounded.CheckBox
|
||||
State.INVERSED -> Icons.Rounded.DisabledByDefault
|
||||
},
|
||||
tint = if (state == State.UNCHECKED) {
|
||||
LocalContentColor.current
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
contentDescription = when (state) {
|
||||
State.UNCHECKED -> stringResource(R.string.general_not_selected)
|
||||
State.CHECKED -> stringResource(R.string.general_selected)
|
||||
State.INVERSED -> stringResource(R.string.disabled)
|
||||
},
|
||||
)
|
||||
Text(text = itemLabel(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (listState.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
|
||||
if (listState.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val included = items.mapIndexedNotNull { index, category ->
|
||||
if (selected[index] == State.CHECKED) category else null
|
||||
}
|
||||
val excluded = items.mapIndexedNotNull { index, category ->
|
||||
if (selected[index] == State.INVERSED) category else null
|
||||
}
|
||||
onValueChanged(included, excluded)
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(R.string.ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.toColorInt
|
||||
|
||||
fun String?.toColor(): Color {
|
||||
if (this == null) return Color.Transparent
|
||||
return Color(this.toColorInt())
|
||||
}
|
||||
|
||||
fun Color.brighter(factor: Float = 0.2f): Color {
|
||||
val hsl = FloatArray(3)
|
||||
ColorUtils.colorToHSL(this.toArgb(), hsl)
|
||||
hsl[2] = (hsl[2] + factor).coerceAtMost(1f) // increase lightness
|
||||
return Color(ColorUtils.HSLToColor(hsl))
|
||||
}
|
||||
|
||||
fun Color.darken(factor: Float = 0.2f): Color {
|
||||
val hsl = FloatArray(3)
|
||||
ColorUtils.colorToHSL(this.toArgb(), hsl)
|
||||
hsl[2] = (hsl[2] - factor).coerceAtLeast(0f) // decrease lightness
|
||||
return Color(ColorUtils.HSLToColor(hsl))
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import androidx.compose.animation.core.EaseInOutQuad
|
||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.material3.DividerDefaults
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
fun Modifier.shimmerEffect(
|
||||
shimmerSize: Dp = 152.dp,
|
||||
shimmerColor: Color = Color.White.copy(alpha = 0.25f),
|
||||
animationSpec: InfiniteRepeatableSpec<Float> = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 1000,
|
||||
delayMillis = 500,
|
||||
easing = EaseInOutQuad,
|
||||
),
|
||||
),
|
||||
) = this.composed {
|
||||
val density = LocalDensity.current
|
||||
val shimmerSizePx = with(density) { shimmerSize.toPx() }
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "Shimmer")
|
||||
val progress by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = animationSpec,
|
||||
label = "ShimmerProgress",
|
||||
)
|
||||
|
||||
Modifier.drawWithContent {
|
||||
drawContent()
|
||||
val adjustedWidth = size.width + shimmerSizePx * 2
|
||||
val x = adjustedWidth * progress - shimmerSizePx
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
shimmerColor,
|
||||
Color.Transparent,
|
||||
),
|
||||
startX = x,
|
||||
endX = x + shimmerSizePx,
|
||||
),
|
||||
topLeft = Offset(x = x, y = 0f),
|
||||
size = Size(
|
||||
width = shimmerSizePx,
|
||||
height = size.height,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(0.78f)
|
||||
|
||||
/**
|
||||
* For TextField, the provided [action] will be invoked when
|
||||
* physical enter key is pressed.
|
||||
*
|
||||
* Naturally, the TextField should be set to single line only.
|
||||
*/
|
||||
fun Modifier.runOnEnterKeyPressed(action: () -> Unit): Modifier = this.onPreviewKeyEvent {
|
||||
when (it.key) {
|
||||
Key.Enter, Key.NumPadEnter -> {
|
||||
// Physical keyboards generate two event types:
|
||||
// - KeyDown when the key is pressed
|
||||
// - KeyUp when the key is released
|
||||
if (it.type == KeyEventType.KeyDown) {
|
||||
action()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For TextField on AppBar, this modifier will request focus
|
||||
* to the element the first time it's composed.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.showSoftKeyboard(show: Boolean): Modifier {
|
||||
if (!show) return this
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var openKeyboard by rememberSaveable { mutableStateOf(show) }
|
||||
LaunchedEffect(focusRequester) {
|
||||
if (openKeyboard) {
|
||||
focusRequester.requestFocus()
|
||||
openKeyboard = false
|
||||
}
|
||||
}
|
||||
return this.focusRequester(focusRequester)
|
||||
}
|
||||
|
||||
/**
|
||||
* For TextField, this modifier will clear focus when soft
|
||||
* keyboard is hidden.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Modifier.clearFocusOnSoftKeyboardHide(
|
||||
onFocusCleared: (() -> Unit)? = null,
|
||||
): Modifier {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
var keyboardShowedSinceFocused by remember { mutableStateOf(false) }
|
||||
if (isFocused) {
|
||||
val imeVisible = WindowInsets.isImeVisible
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(imeVisible) {
|
||||
if (imeVisible) {
|
||||
keyboardShowedSinceFocused = true
|
||||
} else if (keyboardShowedSinceFocused) {
|
||||
focusManager.clearFocus()
|
||||
onFocusCleared?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.onFocusChanged {
|
||||
if (isFocused != it.isFocused) {
|
||||
if (isFocused) {
|
||||
keyboardShowedSinceFocused = false
|
||||
}
|
||||
isFocused = it.isFocused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.onClickInput(
|
||||
pass: PointerEventPass = PointerEventPass.Initial,
|
||||
ripple: Boolean = true,
|
||||
onDown: () -> Unit = {},
|
||||
onUp: () -> Unit = {}
|
||||
): Modifier = composed {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val rippleIndication = if (ripple) ripple() else null
|
||||
|
||||
this
|
||||
.indication(interactionSource, rippleIndication)
|
||||
.pointerInput(pass) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(pass = pass)
|
||||
val press = PressInteraction.Press(down.position)
|
||||
if (ripple) {
|
||||
interactionSource.tryEmit(press) // Start ripple
|
||||
}
|
||||
down.consume()
|
||||
onDown()
|
||||
|
||||
val up = waitForUpOrCancellation(pass)
|
||||
if (up != null) {
|
||||
if (ripple) {
|
||||
interactionSource.tryEmit(PressInteraction.Release(press)) // End ripple
|
||||
}
|
||||
onUp()
|
||||
} else {
|
||||
if (ripple) {
|
||||
interactionSource.tryEmit(PressInteraction.Cancel(press))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.topBorder(
|
||||
strokeWidth: Dp = 1.dp,
|
||||
color: Color = DividerDefaults.color
|
||||
) = composed {
|
||||
val density = LocalDensity.current
|
||||
val strokeWidthPx = density.run { strokeWidth.toPx() }
|
||||
|
||||
Modifier.drawBehind {
|
||||
val width = size.width
|
||||
val height = 0f
|
||||
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(x = 0f, y = height),
|
||||
end = Offset(x = width , y = height),
|
||||
strokeWidth = strokeWidthPx
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.bottomBorder(
|
||||
strokeWidth: Dp = 1.dp,
|
||||
color: Color = DividerDefaults.color
|
||||
) = composed {
|
||||
val density = LocalDensity.current
|
||||
val strokeWidthPx = density.run { strokeWidth.toPx() }
|
||||
|
||||
Modifier.drawBehind {
|
||||
val width = size.width
|
||||
val height = size.height - strokeWidthPx/2
|
||||
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(x = 0f, y = height),
|
||||
end = Offset(x = width , y = height),
|
||||
strokeWidth = strokeWidthPx
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import android.os.Bundle
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import dev.achmad.ledgerr.ui.components.ResultScreen
|
||||
import java.io.Serializable
|
||||
|
||||
fun Navigator.popWithResult(bundle: Bundle) {
|
||||
val prev = if (items.size < 2) null else items[items.size - 2] as? ResultScreen
|
||||
prev?.arguments = bundle
|
||||
pop()
|
||||
}
|
||||
|
||||
fun Navigator.popWithResult(vararg pairs: Pair<String, Any?>) {
|
||||
val bundle = Bundle()
|
||||
for ((key, value) in pairs) {
|
||||
when (value) {
|
||||
null -> bundle.putString(key, null)
|
||||
is Boolean -> bundle.putBoolean(key, value)
|
||||
is Byte -> bundle.putByte(key, value)
|
||||
is Char -> bundle.putChar(key, value)
|
||||
is Double -> bundle.putDouble(key, value)
|
||||
is Float -> bundle.putFloat(key, value)
|
||||
is Int -> bundle.putInt(key, value)
|
||||
is Long -> bundle.putLong(key, value)
|
||||
is Short -> bundle.putShort(key, value)
|
||||
is String -> bundle.putString(key, value)
|
||||
is Bundle -> bundle.putBundle(key, value)
|
||||
is Serializable -> bundle.putSerializable(key, value)
|
||||
else -> throw IllegalArgumentException("Unsupported type for key: $key")
|
||||
}
|
||||
}
|
||||
popWithResult(bundle)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
|
||||
class PermissionState internal constructor(
|
||||
val isGranted: MutableState<Boolean>,
|
||||
val requestPermission: () -> Unit
|
||||
)
|
||||
|
||||
class MultiplePermissionsState internal constructor(
|
||||
val permissions: SnapshotStateMap<String, Boolean>,
|
||||
val requestPermissions: () -> Unit
|
||||
) {
|
||||
fun isAllPermissionsGranted() = permissions.values.all { it }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPermissionState(
|
||||
permission: String,
|
||||
): PermissionState {
|
||||
val context = LocalContext.current
|
||||
val activity = LocalActivity.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val permissionGranted = remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
permissionGranted.value = isGranted
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
permissionGranted.value =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
return remember(permission) {
|
||||
PermissionState(
|
||||
isGranted = permissionGranted,
|
||||
requestPermission = {
|
||||
if (activity != null && !permissionGranted.value) {
|
||||
launcher.launch(permission)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMultiplePermissionsState(
|
||||
permissions: List<String>,
|
||||
): MultiplePermissionsState {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val permissionResults = remember {
|
||||
mutableStateMapOf<String, Boolean>().apply {
|
||||
permissions.forEach { permission ->
|
||||
val granted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
put(permission, granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { resultMap ->
|
||||
resultMap.forEach { (permission, granted) ->
|
||||
permissionResults[permission] = granted
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
permissions.forEach { permission ->
|
||||
val granted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
permissionResults[permission] = granted
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
return remember(permissions) {
|
||||
MultiplePermissionsState(
|
||||
permissions = permissionResults,
|
||||
requestPermissions = {
|
||||
launcher.launch(permissions.toTypedArray())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.arePermissionsAllowed(
|
||||
permissions: List<String>
|
||||
): Boolean {
|
||||
return permissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBackgroundLocationPermissionState(): PermissionState {
|
||||
val activity = LocalActivity.currentOrThrow
|
||||
val applicationContext = activity.applicationContext
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val backgroundLocationPermission = remember {
|
||||
PermissionState(
|
||||
isGranted = mutableStateOf(false),
|
||||
requestPermission = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", applicationContext.packageName, null)
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
backgroundLocationPermission.isGranted.value =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ContextCompat.checkSelfPermission(
|
||||
applicationContext,
|
||||
Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} else true
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
return backgroundLocationPermission
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberNotificationPermissionState(): PermissionState {
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
|
||||
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
else -> {
|
||||
PermissionState(
|
||||
isGranted = remember { mutableStateOf(true) },
|
||||
requestPermission = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.achmad.ledgerr.core.preference.Preference
|
||||
|
||||
@Composable
|
||||
fun <T> Preference<T>.collectAsState(): State<T> {
|
||||
val flow = remember(this) { changes() }
|
||||
return flow.collectAsState(initial = get())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
fun timeFormatter(is24Hour: Boolean): DateTimeFormatter {
|
||||
return if (is24Hour) {
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
} else {
|
||||
DateTimeFormatter.ofPattern("h:mm a")
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toTitleCase(): String {
|
||||
return this.lowercase().split(" ").joinToString(" ") { word ->
|
||||
word.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
class PlaceholderTransformation(val placeholder: String) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
return placeholderFilter(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
fun placeholderFilter(placeholder: String): TransformedText {
|
||||
|
||||
val numberOffsetTranslator = object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(AnnotatedString(placeholder), numberOffsetTranslator)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import coil3.request.ImageRequest
|
||||
|
||||
sealed class UiImage {
|
||||
|
||||
data class DynamicImage(
|
||||
val imageUrl: String,
|
||||
val error: Painter? = null,
|
||||
val placeholder: Painter? = null,
|
||||
) : UiImage()
|
||||
|
||||
data class DrawableResource(
|
||||
@param:DrawableRes val imageDrawable: Int,
|
||||
) : UiImage()
|
||||
|
||||
@Composable
|
||||
fun asPainter(): Painter {
|
||||
return when (this) {
|
||||
is DynamicImage -> {
|
||||
rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.build(),
|
||||
placeholder = placeholder,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
is DrawableResource -> painterResource(imageDrawable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.achmad.ledgerr.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
sealed class UiText {
|
||||
|
||||
data class DynamicText(
|
||||
val text: String,
|
||||
) : UiText()
|
||||
|
||||
data class StringResource(
|
||||
@param:StringRes val res: Int,
|
||||
) : UiText()
|
||||
|
||||
@Composable
|
||||
fun asString(): String {
|
||||
return when (this) {
|
||||
is DynamicText -> text
|
||||
is StringResource -> stringResource(res)
|
||||
}
|
||||
}
|
||||
|
||||
fun asString(context: Context): String {
|
||||
return when (this) {
|
||||
is DynamicText -> text
|
||||
is StringResource -> context.getString(res)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user