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