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:
Achmad Setyabudi Susilo
2026-06-28 15:08:37 +07:00
parent dfca375a9b
commit 3e30423083
50 changed files with 6048 additions and 7 deletions
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>
+245
View File
@@ -0,0 +1,245 @@
# Ledgerr — Agent Instructions
Personal Android expense tracking app. Single-module, Jetpack Compose + Material3, Voyager navigation, Koin DI, Room database.
---
## Package structure
```
dev.achmad.ledgerr/
├── core/
│ ├── network/ - OkHttp helpers (not used by expense features)
│ └── preference/ - PreferenceStore, AndroidPreferenceStore, Preference<T>
├── di/
│ ├── util/
│ │ └── KoinExtensions.kt - inject<T>() and injectLazy<T>() helpers
│ ├── CoreModule.kt
│ ├── DataModule.kt
│ ├── DomainModule.kt
│ └── PreferenceModule.kt - replaces UiModule.kt (delete UiModule if it exists)
├── domain/
│ ├── preference/
│ │ ├── AppPreference.kt
│ │ └── ExpensePreference.kt
│ ├── expense/
│ │ ├── model/
│ │ └── interactor/
│ ├── category/
│ │ ├── model/
│ │ └── interactor/
│ ├── recurring/
│ │ ├── model/
│ │ └── interactor/
│ ├── bankstatement/
│ │ ├── model/
│ │ └── interactor/
│ └── export/
│ └── interactor/
├── data/
│ └── local/
│ ├── AppDatabase.kt
│ ├── converter/
│ ├── dao/
│ ├── entity/
│ └── mapper/
└── ui/
├── base/
│ ├── MainApplication.kt
│ └── MainActivity.kt
├── components/ - shared Compose components (already copied)
│ └── preference/ - PreferenceScreen + widgets
├── screens/
│ ├── home/ HomeScreen.kt
│ ├── expenses/ ExpenseListScreen.kt
│ ├── add_edit_expense/ AddEditExpenseScreen.kt
│ ├── category/ CategoryScreen.kt
│ └── settings/ SettingsScreen.kt
├── theme/
└── util/
```
---
## Architecture rules
### Domain / interactors
- Each folder in `domain/` is a **feature** with `model/` and `interactor/` subpackages.
- Interactor classes are named after the **action**: `GetExpenses`, `UpsertExpense`, `DeleteCategory`, etc.
- Method names follow this convention:
- `await(...)` — single suspend result
- `awaitAll(...)` — list suspend result
- `awaitOne(id)` — fetch one by ID
- `subscribeAll(...)` — returns `Flow`
- Interactors take DAOs (or `Context` for PDF/export) directly as constructor params — **no repository layer**.
- Interactors can contain business logic (think use-cases).
### Dependency injection (Koin)
- **DI modules live in `di/`**, not `core/`.
- `CoreModule` provides `SharedPreferences` and `PreferenceStore` (as `AndroidPreferenceStore`). Nothing else.
- Interactors are registered as `factory { }`, not `single`.
- Preference classes are registered as `single { }` in `PreferenceModule`.
- DAOs are registered as `single { get<AppDatabase>().xyzDao() }` in `DataModule`.
- Bank statement importers use named qualifiers: `factory<BankStatementImporter>(named("bri")) { ... }`.
- **ScreenModels are NOT registered in Koin.** Never add a ScreenModel to any Koin module.
### ScreenModel pattern
Always use `rememberScreenModel { }` in `Screen.Content()`. Inject dependencies via `inject()` as constructor default params:
```kotlin
class HomeScreenModel(
private val getExpenses: GetExpenses = inject(),
private val appPreference: AppPreference = inject(),
) : ScreenModel { ... }
// In Screen.Content():
val screenModel = rememberScreenModel { HomeScreenModel() }
```
`inject<T>()` is at `dev.achmad.ledgerr.di.util.KoinExtensions`.
### Navigation
- Pure Voyager stack — `Navigator`, `navigator.push(...)`, `navigator.pop()`.
- **No `TabNavigator` at root level.**
- `ExpenseListScreen` has 2 internal tabs using plain Compose `TabRow` + `HorizontalPager` (not Voyager tabs).
- Screen flow:
```
HomeScreen (root)
├── FAB expand → "Manual" → AddEditExpenseScreen
│ → "Import Bank Statement" → ImportBankStatementScreen
├── Export icon → date range dialog → SAF CreateDocument → writes CSV
├── "See all" → ExpenseListScreen
│ ├── Tab: Expenses (search + filter)
│ │ └── FAB expand → same as HomeScreen FAB
│ └── Tab: Recurring
├── "Manage Categories" button (dashboard body) → CategoryScreen
└── Settings icon → SettingsScreen
AddEditExpenseScreen → CategoryScreen (manage categories button)
ImportBankStatementScreen (1 Voyager Screen, 1 ScreenModel, 2 composable functions)
├── ImportBankStatementPickerContent — state is BankPicker or Processing
│ select bank → SAF OpenDocument → loading overlay while parsing
└── ImportBankStatementConfirmationContent — state is Confirmation
toggle/edit per row → InsertExpenses → navigator.pop()
```
- **Export** has no dedicated screen. Icon in `HomeScreen` and `ExpenseListScreen` top bars; also a `TextPreference` in `SettingsScreen`. Tapping shows a date range dialog first, then opens SAF `CreateDocument`. CSV uses ISO 8601 dates (`yyyy-MM-dd`) with UTF-8 BOM.
- **Import** goes through `ImportBankStatementScreen` — one Voyager Screen, one ScreenModel, two internal composables switched by `StateFlow<ImportState>`. `ImportBankStatementPickerContent` handles bank selection and processing; `ImportBankStatementConfirmationContent` handles review, edit, and confirm. On confirm calls `InsertExpenses.awaitAll()` then pops.
### Settings screen
`SettingsScreen` uses the `PreferenceScreen` composable from `ui/components/preference/`. Pattern:
```kotlin
PreferenceScreen(
itemsProvider = {
listOf(
Preference.PreferenceItem.ListPreference(
title = "Theme",
preference = appPreference.appTheme(),
entries = mapOf(...),
),
Preference.PreferenceItem.TextPreference(
title = "Export CSV",
onClick = { /* launch SAF CreateDocument */ }
),
Preference.PreferenceItem.AlertDialogPreference(
title = "Clear all data",
onConfirm = { screenModel.clearData() }
),
)
}
)
```
### Preference classes
Located in `domain/preference/`. Take `PreferenceStore` as constructor param. Methods return `Preference<T>` objects from `core/preference/`:
```kotlin
class AppPreference(private val store: PreferenceStore) {
fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
}
```
### Data layer
- `LocalDate` stored as `Long` epoch day via `LocalDateConverter` (`LocalDate.toEpochDay()` / `LocalDate.ofEpochDay()`).
- Use `@Upsert` Room annotation for insert-or-update operations.
- `AppDatabase.Callback.onCreate` seeds default categories using `CoroutineScope(Dispatchers.IO)` — Koin is not ready inside Room callbacks.
- minSdk 26, so `java.time.*` is available without desugaring.
### Bank statement import
- `BankStatementImporter` interface: `val bankName: String` + `suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>>`.
- Stubs: `ImportBRIBankStatement`, `ImportJagoBankStatement`, `ImportBNIBankStatement` — all return `Result.failure(NotImplementedError(...))`.
- PDF text extraction: `com.tom-roush:pdfbox-android`. Requires `PDFBoxResourceLoader.init(applicationContext)` in `MainApplication.onCreate()`.
- No manifest permissions needed — SAF handles file access.
### Export
- `ExportExpensesToCsv` interactor writes UTF-8 BOM CSV via Okio to a SAF URI.
- SAF launchers (`ActivityResultContracts.OpenDocument`, `ActivityResultContracts.CreateDocument`) are registered in the Screen composable; URIs are passed to the ScreenModel.
---
## Workflow for new features
Follow these steps in order every time. Do not skip ahead to implementation without explicit approval.
1. **Define structs** — add any new models to the relevant `domain/<feature>/model/` package.
2. **Define interfaces** — add or update interactor class signatures and method stubs in `domain/<feature>/interactor/`. If a new feature has no existing feature folder, create one.
3. **Add TODOs** — fill method bodies with `TODO("...")` comments describing the intended behavior, edge cases, and any invariants.
4. **Self-review** — think through the full implementation. If anything requires going outside or beyond what was defined in steps 13 (new models, new methods, changed signatures), go back to the relevant step and update it before continuing.
5. **Prompt for review** — stop and present a summary of what was defined. Wait for an explicit go-ahead before writing any real implementation code.
6. **Implement** — only after approval: replace TODOs with real code, wire up DI in the relevant module, update any callers.
---
## Key dependencies
| Library | Version | Purpose |
|---------|---------|---------|
| Jetpack Compose + Material3 | (from BOM) | UI |
| Voyager | — | Navigation + ScreenModel |
| Koin | 4.2.2 | DI |
| Room | 2.7.1 | Local DB |
| PDFBox-Android | 2.0.27.0 | PDF text extraction |
| Okio | (transitive) | CSV write |
No chart library — use Canvas (`drawArc` for pie, `drawRect` for bar).
---
## Git
- **Never commit or push unless the user explicitly asks.** Do not auto-commit after completing a task, do not squash, amend, or rebase without being told to.
- **Never add `Co-Authored-By` lines to commit messages.**
---
## Things to avoid
- Do not register ScreenModels in Koin.
- Do not create a repository layer between interactors and DAOs.
- Do not add a `TabNavigator` at the root.
- Do not create a dedicated screen for export — it is a direct action (date range dialog → SAF picker).
- Do not add manifest permissions for file access — SAF handles it.
- Do not use an external chart library.
- Do not rename `MainApplication` back to `LedgerrApp`.
- `UiModule.kt` has been deleted — do not recreate it; the replacement is `PreferenceModule.kt`.
- Do not use `@KoinViewModel` or `viewModel {}` — this project uses Voyager `ScreenModel`.
---
## Docs
Full design documentation is in `docs/`:
- `01-data-model.md` — domain models per feature
- `02-interfaces.md` — all interactor signatures
- `03-function-todos.md` — per-method behavior and edge cases
- `04-implementation-plan.md` — package structure, DI wiring, build order
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
@@ -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 }
}
@@ -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))
}
}
)
}
}
@@ -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
@@ -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
}
},
)
}
}
@@ -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) }
)
}
@@ -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,
)
}
}
@@ -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),
)
}
}
@@ -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),
)
}
}
@@ -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
}
},
)
}
}
@@ -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
)
}
@@ -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,
)
}
},
)
}
@@ -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) },
)
}
@@ -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,
)
}
@@ -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)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
# 01 — Data Model
Models are scoped to their feature under `dev.achmad.ledgerr.domain.<feature>.model`. No shared/common package — features import from each other's model package where needed.
---
## Feature: expense
```kotlin
// DateRange.kt
data class DateRange(
val start: LocalDate,
val end: LocalDate
) {
companion object {
fun thisMonth(): DateRange {
val now = LocalDate.now()
return DateRange(now.withDayOfMonth(1), now.withDayOfMonth(now.lengthOfMonth()))
}
fun thisWeek(): DateRange {
val now = LocalDate.now()
return DateRange(now.with(DayOfWeek.MONDAY), now.with(DayOfWeek.SUNDAY))
}
}
}
// Expense.kt
data class Expense(
val id: Long = 0,
val amount: Double, // always positive; represents outflow
val categoryId: Long,
val date: LocalDate,
val note: String? = null,
val recurringExpenseId: Long? = null, // set if auto-generated from a RecurringExpense
val createdAt: Long = System.currentTimeMillis()
)
// ExpenseWithCategory.kt
data class ExpenseWithCategory(
val expense: Expense,
val category: Category // imported from domain.category.model
)
// ExpenseSummary.kt
data class ExpenseSummary(
val totalAmount: Double,
val byCategory: List<Pair<Category, Double>>, // sorted by amount DESC
val period: DateRange
)
```
---
## Feature: category
```kotlin
// Category.kt
data class Category(
val id: Long = 0,
val name: String,
val color: Int, // ARGB
val iconName: String? = null, // Material icon name
val isDefault: Boolean = false // non-deletable; "Other" is always true
)
```
Default categories seeded on first install:
| Name | color (ARGB hex) | isDefault |
|------|------------------|-----------|
| Food & Drink | `0xFFFF9800` (orange) | false |
| Transport | `0xFF2196F3` (blue) | false |
| Housing | `0xFF795548` (brown) | false |
| Health | `0xFFF44336` (red) | false |
| Entertainment | `0xFF9C27B0` (purple) | false |
| Shopping | `0xFFE91E63` (pink) | false |
| Education | `0xFF4CAF50` (green) | false |
| Other | `0xFF9E9E9E` (grey) | **true** |
`Other` is the permanent fallback when a user-defined category is deleted.
---
## Feature: recurring
```kotlin
// RecurringInterval.kt
enum class RecurringInterval {
DAILY, WEEKLY, MONTHLY, YEARLY;
fun advance(from: LocalDate): LocalDate = when (this) {
DAILY -> from.plusDays(1)
WEEKLY -> from.plusWeeks(1)
MONTHLY -> from.plusMonths(1)
YEARLY -> from.plusYears(1)
}
}
// RecurringExpense.kt
data class RecurringExpense(
val id: Long = 0,
val amount: Double,
val categoryId: Long,
val note: String? = null,
val interval: RecurringInterval,
val startDate: LocalDate,
val nextDueDate: LocalDate, // initialized = startDate; advances after each processing
val isActive: Boolean = true
)
// RecurringExpenseWithCategory.kt
data class RecurringExpenseWithCategory(
val recurring: RecurringExpense,
val category: Category
)
```
---
## Feature: bankstatement
Each bank has its own raw model representing a single parsed row from the bank's PDF format. These exist so bank-specific parsing logic produces typed data before converting to the common `PendingImportExpense`.
```kotlin
// PendingImportExpense.kt — common output for the review screen
data class PendingImportExpense(
val amount: Double,
val date: LocalDate,
val description: String, // raw text from PDF
val suggestedCategoryId: Long? = null,
val isSelected: Boolean = true // user can deselect before committing
)
// BRIStatementEntry.kt
data class BRIStatementEntry(
val date: String, // raw string as it appears in PDF
val description: String,
val debit: String?, // raw amount string, null if not a debit
val credit: String?,
val balance: String?
)
// JagoStatementEntry.kt
data class JagoStatementEntry(
val date: String,
val description: String,
val amount: String,
val type: String // e.g. "DEBIT" / "KREDIT"
)
// BNIStatementEntry.kt
data class BNIStatementEntry(
val date: String,
val description: String,
val debit: String?,
val credit: String?,
val balance: String?
)
```
---
## Feature: export
No domain models — uses `Expense`, `ExpenseWithCategory`, and `DateRange` from the expense feature.
---
## Room Entities (data layer only)
Live in `data/local/entity/`. Mirror domain models with Room annotations; dates stored as `Long` (epoch day via `LocalDateConverter`).
- `CategoryEntity`
- `ExpenseEntity`
- `RecurringExpenseEntity`
+185
View File
@@ -0,0 +1,185 @@
# 02 — Interactors
Each feature under `dev.achmad.ledgerr.domain.<feature>.interactor` owns a set of interactor classes. Interactors are use-case classes that can hold logic and depend on whatever they need (DAOs, Android context, etc.). The UI layer depends only on domain — never on data directly.
Naming convention for methods:
- `await(...)` — suspend, returns a single value
- `awaitAll(...)` — suspend, returns a list
- `subscribeOne(...)` — returns `Flow<T?>`
- `subscribeAll(...)` — returns `Flow<List<T>>`
- Filters are parameters, not separate functions
---
## Feature: expense
### `GetExpenses`
```kotlin
class GetExpenses(dao: ExpenseDao, categoryDao: CategoryDao) {
fun subscribeAll(): Flow<List<ExpenseWithCategory>>
fun subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>>
suspend fun awaitOne(id: Long): ExpenseWithCategory?
suspend fun awaitAll(query: String = "", range: DateRange? = null): List<ExpenseWithCategory>
}
```
### `UpsertExpense`
```kotlin
class UpsertExpense(dao: ExpenseDao) {
suspend fun await(expense: Expense): Long // insert if id=0, update otherwise
}
```
### `InsertExpenses`
```kotlin
class InsertExpenses(dao: ExpenseDao) {
suspend fun awaitAll(expenses: List<Expense>) // bulk insert, single transaction
}
```
### `DeleteExpense`
```kotlin
class DeleteExpense(dao: ExpenseDao) {
suspend fun await(id: Long)
}
```
### `ReassignExpenseCategory`
```kotlin
class ReassignExpenseCategory(dao: ExpenseDao) {
suspend fun await(fromCategoryId: Long, toCategoryId: Long)
}
```
### `GetExpenseSummary`
```kotlin
class GetExpenseSummary(dao: ExpenseDao, categoryDao: CategoryDao) {
suspend fun await(range: DateRange): ExpenseSummary
}
```
---
## Feature: category
### `GetCategories`
```kotlin
class GetCategories(dao: CategoryDao) {
fun subscribeAll(): Flow<List<Category>>
suspend fun awaitOne(id: Long): Category?
suspend fun awaitAll(): List<Category>
suspend fun awaitDefault(): Category // returns the isDefault=true "Other" category
}
```
### `UpsertCategory`
```kotlin
class UpsertCategory(dao: CategoryDao) {
suspend fun await(category: Category): Long
}
```
### `DeleteCategory`
```kotlin
class DeleteCategory(
dao: CategoryDao,
reassignExpenseCategory: ReassignExpenseCategory,
getCategories: GetCategories,
) {
suspend fun await(id: Long)
// internally: reassign orphaned expenses to "Other", then delete
}
```
### `SeedDefaultCategories`
```kotlin
class SeedDefaultCategories(dao: CategoryDao) {
suspend fun await() // no-op if categories already exist
}
```
---
## Feature: recurring
### `GetRecurringExpenses`
```kotlin
class GetRecurringExpenses(dao: RecurringExpenseDao, categoryDao: CategoryDao) {
fun subscribeAll(): Flow<List<RecurringExpenseWithCategory>>
suspend fun awaitOne(id: Long): RecurringExpense?
}
```
### `UpsertRecurringExpense`
```kotlin
class UpsertRecurringExpense(dao: RecurringExpenseDao) {
suspend fun await(recurring: RecurringExpense): Long
}
```
### `DeleteRecurringExpense`
```kotlin
class DeleteRecurringExpense(dao: RecurringExpenseDao) {
suspend fun await(id: Long)
// does NOT delete expense instances already created from this template
}
```
### `ProcessDueRecurringExpenses`
```kotlin
class ProcessDueRecurringExpenses(
recurringDao: RecurringExpenseDao,
expenseDao: ExpenseDao,
) {
suspend fun await(today: LocalDate = LocalDate.now()): List<Expense>
}
```
---
## Feature: bankstatement
### `BankStatementImporter` (interface)
```kotlin
interface BankStatementImporter {
val bankName: String
suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>>
}
```
### `ImportBRIBankStatement : BankStatementImporter`
```kotlin
class ImportBRIBankStatement(context: Context) : BankStatementImporter {
override val bankName = "BRI"
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
}
```
### `ImportJagoBankStatement : BankStatementImporter`
```kotlin
class ImportJagoBankStatement(context: Context) : BankStatementImporter {
override val bankName = "Jago"
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
}
```
### `ImportBNIBankStatement : BankStatementImporter`
```kotlin
class ImportBNIBankStatement(context: Context) : BankStatementImporter {
override val bankName = "BNI"
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
}
```
`DomainModule` binds all three as `List<BankStatementImporter>` so the UI can show a bank picker.
---
## Feature: export
### `ExportExpensesToCsv`
```kotlin
class ExportExpensesToCsv(expenseDao: ExpenseDao, categoryDao: CategoryDao, context: Context) {
suspend fun await(range: DateRange, outputUri: Uri): Result<Unit>
}
```
+174
View File
@@ -0,0 +1,174 @@
# 03 — Function TODOs
What each interactor method does, inputs, outputs, and edge cases.
---
## expense / GetExpenses
### `subscribeAll(): Flow<List<ExpenseWithCategory>>`
JOIN expenses with categories, ordered by `date DESC`, `createdAt DESC`. Emits on any change to either table.
### `subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>>`
Same join, filtered: `expense.date >= range.start AND expense.date <= range.end`. Ordered date DESC.
### `awaitOne(id: Long): ExpenseWithCategory?`
Single JOIN lookup by expense id. Returns null if not found.
### `awaitAll(query: String, range: DateRange?): List<ExpenseWithCategory>`
One-shot search. If `query` is blank and `range` is null, returns all expenses (ordered date DESC). If `query` is non-blank, filters: `note LIKE '%query%'` OR `CAST(amount AS TEXT) LIKE '%query%'` OR `category.name LIKE '%query%'`. If `range` is non-null, also filters by date range. Results ordered date DESC.
---
## expense / UpsertExpense
### `await(expense: Expense): Long`
If `expense.id == 0L`: insert and return generated id. Otherwise: update by id and return the existing id. Uses Room `@Upsert`.
---
## expense / InsertExpenses
### `awaitAll(expenses: List<Expense>)`
Bulk insert in a single Room transaction. Used after the user confirms PDF import. `expense.id` must be 0 for all items.
---
## expense / DeleteExpense
### `await(id: Long)`
Deletes expense by primary key. Does not touch any `RecurringExpense` template that generated it — those continue running independently.
---
## expense / ReassignExpenseCategory
### `await(fromCategoryId: Long, toCategoryId: Long)`
Single SQL `UPDATE expenses SET categoryId = :toCategoryId WHERE categoryId = :fromCategoryId`. Used by `DeleteCategory` before removing a category.
---
## expense / GetExpenseSummary
### `await(range: DateRange): ExpenseSummary`
1. Fetch all expenses with category in range (one-shot, uses DAO directly).
2. Sum all amounts → `totalAmount`.
3. Group by category, sum per group, sort by amount DESC → `byCategory`.
4. Return `ExpenseSummary(totalAmount, byCategory, range)`.
---
## category / GetCategories
### `subscribeAll(): Flow<List<Category>>`
All categories ordered by name ASC, emits on change.
### `awaitOne(id: Long): Category?`
Nullable lookup by primary key.
### `awaitAll(): List<Category>`
One-shot list, ordered by name ASC.
### `awaitDefault(): Category`
Returns the single category where `isDefault = true`. Throws `IllegalStateException` if not found (seeding hasn't run — should not happen in practice).
---
## category / UpsertCategory
### `await(category: Category): Long`
Insert if `id == 0`, update otherwise. Returns the id. Does not allow changing `isDefault` — if the incoming category has `isDefault = true` but the existing DB record has `isDefault = false`, the DB value wins (impl reads existing `isDefault` before update). In practice, only the seeder sets `isDefault = true`.
---
## category / DeleteCategory
### `await(id: Long)`
1. Guard: fetch category by id. If `isDefault = true`, throw `IllegalArgumentException("Cannot delete a default category")`.
2. Find the fallback: call `getCategories.awaitDefault()` to get the "Other" category id.
3. Call `reassignExpenseCategory.await(id, fallbackId)` to move orphaned expenses.
4. Delete the category from the DB.
Steps 3 and 4 run sequentially in the interactor (not a DB transaction). If step 4 fails, expenses are already reassigned to Other — acceptable, retry is safe.
---
## category / SeedDefaultCategories
### `await()`
Checks count of categories in DB. If count > 0, no-op. Otherwise inserts the 8 default categories (see `01-data-model.md`). Called from `MainApplication.onCreate` after `startKoin` completes, using a `CoroutineScope(Dispatchers.IO)`.
---
## recurring / GetRecurringExpenses
### `subscribeAll(): Flow<List<RecurringExpenseWithCategory>>`
All recurring expenses (active and inactive) joined with category, ordered by `nextDueDate ASC`. Emits on change.
### `awaitOne(id: Long): RecurringExpense?`
Nullable lookup by primary key.
---
## recurring / UpsertRecurringExpense
### `await(recurring: RecurringExpense): Long`
Insert if `id == 0`, update otherwise. Caller must set `nextDueDate = startDate` on initial insert.
---
## recurring / DeleteRecurringExpense
### `await(id: Long)`
Deletes the recurring template by id. Expense instances previously created from this template retain their `recurringExpenseId` value — they become historical records and are not deleted.
---
## recurring / ProcessDueRecurringExpenses
### `await(today: LocalDate): List<Expense>`
1. Query recurring expenses where `isActive = true AND nextDueDate <= today`.
2. For each due item:
a. Create `Expense(amount, categoryId, note from template, date = nextDueDate, recurringExpenseId = template.id)`.
b. Insert the expense.
c. Advance: `newNextDueDate = interval.advance(current nextDueDate)`.
d. Update the recurring template with `nextDueDate = newNextDueDate`.
3. Return the list of created expenses so the caller can show a banner.
Edge case — app not opened for multiple intervals: advances `nextDueDate` by one interval per call. The next app open will process it again if still overdue. This avoids flooding the expense list with back-filled entries.
---
## bankstatement / BankStatementImporter
### `val bankName: String`
Display name shown in the bank picker (e.g. `"BRI"`, `"Jago"`, `"BNI"`).
### `await(pdfUri: Uri): Result<List<PendingImportExpense>>`
Contract for all implementations:
1. Open PDF via `ContentResolver.openInputStream(pdfUri)`.
2. Extract text with PDFBox-Android (`PDDocument` + `PDFTextStripper`), page by page.
3. Parse bank-specific format into raw entry models (e.g. `BRIStatementEntry`).
4. Filter to debit-only rows (skip credits/income).
5. Map raw entries to `PendingImportExpense` — parse Indonesian number format (`1.500.000,00`), parse date strings to `LocalDate`.
6. Return `Result.success(list)` or `Result.failure(exception)`.
**`ImportBRIBankStatement.await`** — stub: `Result.failure(NotImplementedError("BRI import not yet implemented"))`.
**`ImportJagoBankStatement.await`** — stub: same.
**`ImportBNIBankStatement.await`** — stub: same.
---
## export / ExportExpensesToCsv
### `await(range: DateRange, outputUri: Uri): Result<Unit>`
1. Fetch all `ExpenseWithCategory` in range from DAO (one-shot).
2. Open `OutputStream` via `context.contentResolver.openOutputStream(outputUri)`.
3. Wrap with Okio `BufferedSink`.
4. Write UTF-8 BOM (`0xEF, 0xBB, 0xBF`) for Excel compatibility.
5. Write header: `Date,Category,Amount,Note`.
6. For each expense: `date` in ISO 8601 (`yyyy-MM-dd`), then `category.name`, `amount`, `"note"` (double-quote the note to handle embedded commas).
7. Close sink.
8. Return `Result.success(Unit)` or `Result.failure(exception)`.
+412
View File
@@ -0,0 +1,412 @@
# 04 — Implementation Plan
---
## Dependencies to Add
In `gradle/libs.versions.toml`:
```toml
[versions]
room = "2.7.1"
pdfbox_android = "2.0.27.0"
[libraries]
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox_android" }
```
In `app/build.gradle.kts`:
```kotlin
ksp(libs.room.compiler)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
implementation(libs.pdfbox.android)
```
**No chart library** — Canvas-based (`drawArc` for pie, `drawRect` for bars).
**No manifest permissions** — SAF handles file access.
---
## Package Structure
```
dev.achmad.ledgerr/
├── core/ (existing)
│ ├── network/
│ └── preference/
├── data/
│ └── local/
│ ├── AppDatabase.kt
│ ├── converter/
│ │ └── LocalDateConverter.kt - LocalDate <-> Long (epoch day)
│ ├── dao/
│ │ ├── CategoryDao.kt
│ │ ├── ExpenseDao.kt
│ │ └── RecurringExpenseDao.kt
│ ├── entity/
│ │ ├── CategoryEntity.kt
│ │ ├── ExpenseEntity.kt
│ │ └── RecurringExpenseEntity.kt
│ └── mapper/
│ ├── CategoryMapper.kt
│ ├── ExpenseMapper.kt
│ └── RecurringExpenseMapper.kt
├── di/
│ ├── CoreModule.kt - provides PreferenceStore as AndroidPreferenceStore
│ ├── DataModule.kt - DB, DAOs
│ ├── DomainModule.kt - all interactors
│ ├── PreferenceModule.kt - preference class singletons
│ └── util/
│ └── KoinExtensions.kt (existing)
├── domain/
│ ├── preference/
│ │ ├── AppPreference.kt - app-wide: theme
│ │ └── ExpensePreference.kt - expense display: default date range filter
│ │
│ ├── expense/
│ │ ├── model/
│ │ │ ├── DateRange.kt
│ │ │ ├── Expense.kt
│ │ │ ├── ExpenseWithCategory.kt
│ │ │ └── ExpenseSummary.kt
│ │ └── interactor/
│ │ ├── GetExpenses.kt
│ │ ├── UpsertExpense.kt
│ │ ├── InsertExpenses.kt
│ │ ├── DeleteExpense.kt
│ │ ├── ReassignExpenseCategory.kt
│ │ └── GetExpenseSummary.kt
│ │
│ ├── category/
│ │ ├── model/
│ │ │ └── Category.kt
│ │ └── interactor/
│ │ ├── GetCategories.kt
│ │ ├── UpsertCategory.kt
│ │ ├── DeleteCategory.kt
│ │ └── SeedDefaultCategories.kt
│ │
│ ├── recurring/
│ │ ├── model/
│ │ │ ├── RecurringExpense.kt
│ │ │ ├── RecurringExpenseWithCategory.kt
│ │ │ └── RecurringInterval.kt
│ │ └── interactor/
│ │ ├── GetRecurringExpenses.kt
│ │ ├── UpsertRecurringExpense.kt
│ │ ├── DeleteRecurringExpense.kt
│ │ └── ProcessDueRecurringExpenses.kt
│ │
│ ├── bankstatement/
│ │ ├── model/
│ │ │ ├── PendingImportExpense.kt
│ │ │ ├── BRIStatementEntry.kt
│ │ │ ├── JagoStatementEntry.kt
│ │ │ └── BNIStatementEntry.kt
│ │ └── interactor/
│ │ ├── BankStatementImporter.kt - interface
│ │ ├── ImportBRIBankStatement.kt - stub
│ │ ├── ImportJagoBankStatement.kt - stub
│ │ └── ImportBNIBankStatement.kt - stub
│ │
│ └── export/
│ └── interactor/
│ └── ExportExpensesToCsv.kt
└── ui/
├── base/
│ ├── MainApplication.kt
│ └── MainActivity.kt (existing)
├── components/ (copied from info-krl-android, see below)
│ ├── AppBar.kt
│ ├── CardSection.kt
│ ├── FilterChipGroup.kt
│ ├── HelpCard.kt
│ ├── LabeledCheckbox.kt
│ ├── LabeledRadioButton.kt
│ ├── LazyGridScrollbar.kt
│ ├── LazyListScrollbar.kt
│ ├── LinkIcon.kt
│ ├── Pill.kt
│ ├── ResultScreen.kt
│ ├── ScrollbarLazyColumn.kt
│ ├── ScrollbarLazyGrid.kt
│ ├── SpotlightOverlay.kt
│ ├── TabText.kt
│ └── preference/ (copied from info-krl-android)
│ ├── Preference.kt
│ ├── PreferenceItem.kt
│ ├── PreferenceScreen.kt
│ └── widget/
│ ├── AlertDialogPreferenceWidget.kt
│ ├── BasePreferenceWidget.kt
│ ├── BasicMultiSelectListPreferenceWidget.kt
│ ├── CheckPreferenceWidget.kt
│ ├── EditTextPreferenceWidget.kt
│ ├── InfoWidget.kt
│ ├── ListPreferenceWidget.kt
│ ├── ListSearchPreferenceWidget.kt
│ ├── MultiSelectListPreferenceWidget.kt
│ ├── PermissionPreferenceWidget.kt
│ ├── PreferenceGroupHeader.kt
│ ├── SwitchPreferenceWidget.kt
│ ├── TextPreferenceWidget.kt
│ └── TriStateListDialog.kt
├── screens/
│ ├── home/
│ │ └── HomeScreen.kt - dashboard + recent expenses
│ ├── expenses/
│ │ └── ExpenseListScreen.kt - 2-tab screen (Expenses | Recurring)
│ ├── add_edit_expense/
│ │ └── AddEditExpenseScreen.kt - shared add & edit
│ ├── category/
│ │ └── CategoryScreen.kt
│ ├── import_bank_statement/
│ │ └── ImportBankStatementScreen.kt - 1 Voyager Screen, 2 internal composables:
│ │ ImportBankStatementPickerContent (bank picker + SAF trigger + processing)
│ │ ImportBankStatementConfirmationContent (review/edit/confirm)
│ └── settings/
│ └── SettingsScreen.kt - uses PreferenceScreen component
├── util/ (copied from info-krl-android)
│ ├── ColorUtil.kt
│ ├── ModifierUtil.kt
│ ├── NavigatorUtil.kt
│ ├── PreferenceUtil.kt
│ ├── StringUtil.kt
│ ├── TextFieldUtil.kt
│ ├── UiImage.kt
│ └── UiText.kt
└── theme/ (existing)
├── Theme.kt
└── Type.kt
```
### ScreenModel pattern
ScreenModels are **not** registered in Koin. Interactors and preference classes are injected as constructor default parameters using `inject()` from `KoinExtensions`:
```kotlin
class HomeScreenModel(
private val getExpenses: GetExpenses = inject(),
private val getExpenseSummary: GetExpenseSummary = inject(),
private val processRecurring: ProcessDueRecurringExpenses = inject(),
private val appPreference: AppPreference = inject(),
) : ScreenModel { ... }
```
In `Screen.Content()`:
```kotlin
val screenModel = rememberScreenModel { HomeScreenModel() }
```
---
## Preference Classes
### `domain/preference/AppPreference.kt`
```kotlin
class AppPreference(private val store: PreferenceStore) {
fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
}
enum class AppTheme { LIGHT, DARK, SYSTEM }
```
### `domain/preference/ExpensePreference.kt`
```kotlin
class ExpensePreference(private val store: PreferenceStore) {
fun defaultDateRange() = store.getEnum("expense_default_date_range", defaultValue = DateRangeOption.THIS_MONTH)
}
enum class DateRangeOption { THIS_WEEK, THIS_MONTH }
```
---
## Navigation
Pure stack navigation using Voyager `Navigator`. No `TabNavigator` at the root level.
```
HomeScreen (root)
├── FAB (expanded) ─────────────────────────── "Manual" → AddEditExpenseScreen
│ "Import Bank Statement" → ImportBankStatementScreen
├── Export icon ────────────────────────────── date range dialog → SAF CreateDocument → writes CSV
├── "See all" / "View expenses" button ──────── → ExpenseListScreen
│ ├── Tab 1: Expenses list (search + filter)
│ │ └── FAB (expanded) → "Manual" → AddEditExpenseScreen
│ │ → "Import" → ImportBankStatementScreen
│ └── Tab 2: Recurring list
├── "Manage Categories" button (dashboard) ──── → CategoryScreen
└── Settings icon ──────────────────────────── → SettingsScreen
AddEditExpenseScreen
└── "Manage Categories" button ───────────────── → CategoryScreen
ImportBankStatementScreen (1 Voyager Screen, 1 ScreenModel, 2 composable functions)
├── ImportBankStatementPickerContent — rendered when state is BankPicker or Processing
│ • list of banks from List<BankStatementImporter>
│ • selecting a bank opens SAF OpenDocument picker (PDF)
│ • loading overlay on top while state is Processing
└── ImportBankStatementConfirmationContent — rendered when state is Confirmation
• list of PendingImportExpense
• toggle selection per row
• edit amount / date / description / category inline
• "Import X items" → InsertExpenses.awaitAll() → navigator.pop()
```
Notes:
- **ExpenseListScreen tabs** are plain Compose `TabRow` / `HorizontalPager` — not Voyager tabs.
- **FAB expansion** on both `HomeScreen` and `ExpenseListScreen`: tapping reveals two sub-actions; second tap or outside tap collapses it.
- **Export** — tapping the export icon shows a date range picker dialog (start + end date); after confirming the range the SAF `CreateDocument` picker opens. The ScreenModel writes the CSV (ISO 8601 dates, UTF-8 BOM) when the URI comes back. Export icon lives in `HomeScreen` top bar, `ExpenseListScreen` top bar, and as a `TextPreference` in `SettingsScreen`.
- **Import** goes through `ImportBankStatementScreen` (one Voyager Screen, one ScreenModel). The ScreenModel owns a `StateFlow<ImportState>` that switches between two internal composables: `ImportBankStatementPickerContent` (BankPicker + Processing overlay) and `ImportBankStatementConfirmationContent` (review, edit, confirm). On confirmation the ScreenModel calls `InsertExpenses.awaitAll()` then pops.
- **CategoryScreen** reachable from the "Manage Categories" button on `HomeScreen` dashboard and from inside `AddEditExpenseScreen`.
- **SettingsScreen** uses `PreferenceScreen` component (same pattern as `MoreTab` in info-krl-android). Exposes: theme selector (`ListPreference`), export action (`TextPreference`), clear data (`AlertDialogPreference`).
---
## DI Wiring
### CoreModule
```kotlin
val coreModule = module {
single {
get<Context>().getSharedPreferences("ledgerr_prefs", Context.MODE_PRIVATE)
}
single<PreferenceStore> { AndroidPreferenceStore(get()) }
}
```
### DataModule
```kotlin
val dataModule = module {
single {
Room.databaseBuilder(androidApplication(), AppDatabase::class.java, "ledgerr.db").build()
}
single { get<AppDatabase>().categoryDao() }
single { get<AppDatabase>().expenseDao() }
single { get<AppDatabase>().recurringExpenseDao() }
}
```
### DomainModule
```kotlin
val domainModule = module {
// expense
factory { GetExpenses(get(), get()) }
factory { UpsertExpense(get()) }
factory { InsertExpenses(get()) }
factory { DeleteExpense(get()) }
factory { ReassignExpenseCategory(get()) }
factory { GetExpenseSummary(get(), get()) }
// category
factory { GetCategories(get()) }
factory { UpsertCategory(get()) }
factory { DeleteCategory(get(), get(), get()) }
factory { SeedDefaultCategories(get()) }
// recurring
factory { GetRecurringExpenses(get(), get()) }
factory { UpsertRecurringExpense(get()) }
factory { DeleteRecurringExpense(get()) }
factory { ProcessDueRecurringExpenses(get(), get()) }
// bankstatement
factory<BankStatementImporter>(named("bri")) { ImportBRIBankStatement(androidContext()) }
factory<BankStatementImporter>(named("jago")) { ImportJagoBankStatement(androidContext()) }
factory<BankStatementImporter>(named("bni")) { ImportBNIBankStatement(androidContext()) }
factory<List<BankStatementImporter>> {
listOf(get(named("bri")), get(named("jago")), get(named("bni")))
}
// export
factory { ExportExpensesToCsv(get(), get(), androidContext()) }
}
```
### PreferenceModule
```kotlin
val preferenceModule = module {
single { AppPreference(get()) }
single { ExpensePreference(get()) }
}
```
---
## Key Implementation Details
### MainApplication
```kotlin
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
PDFBoxResourceLoader.init(this)
startKoin {
androidLogger()
androidContext(this@MainApplication)
modules(coreModule, dataModule, domainModule, preferenceModule)
}
CoroutineScope(Dispatchers.IO).launch {
inject<SeedDefaultCategories>().await()
}
}
}
```
Register in `AndroidManifest.xml` as `android:name=".ui.base.MainApplication"`.
### AppDatabase seeding
After `startKoin` completes in `MainApplication.onCreate`, call `SeedDefaultCategories` via a `CoroutineScope(Dispatchers.IO)`:
```kotlin
CoroutineScope(Dispatchers.IO).launch {
inject<SeedDefaultCategories>().await()
}
```
`SeedDefaultCategories.await()` is a no-op if categories already exist, so this is safe to call on every launch. No Room Callback is used for seeding.
### Recurring processor trigger
`HomeScreen`'s ScreenModel calls `ProcessDueRecurringExpenses` in `init {}`. If any are added, a dismissable banner appears on the dashboard.
### SAF file pickers
- PDF import: `ActivityResultContracts.OpenDocument(arrayOf("application/pdf"))`
- CSV export: `ActivityResultContracts.CreateDocument("text/csv")`
Launchers registered in the Screen composable; URIs passed to the ScreenModel.
---
## Build Order
1. Domain models (all features)
2. Domain preference classes
3. Data: entities, DAOs, `LocalDateConverter`, `AppDatabase`
4. Mappers
5. Domain interactors (category → expense → recurring → bank stubs → export)
6. DI modules
7. `MainApplication` + manifest
8. Screens: `HomeScreen``ExpenseListScreen``AddEditExpenseScreen``CategoryScreen``ImportBankStatementScreen``SettingsScreen`
9. UI components extracted as needed during screen work