From 2183e5d8b33ff8571dd8233ffd7565cbc6444e41 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 12 May 2026 22:27:48 +0200 Subject: [PATCH] feat: add calculator v61 app & os widget fix: simplify scan button highlight fix: keep tab bar fully opaque fix: keep tab bar opaque on exit fix: sync calculator tab bar transition fix: align fiat placeholder text fix: place cursor before placeholder fix: shrink calculator widget preview rows fix: show fiat decimal placeholder chore: fix naming agent rules fix: match calculator cursor style fix: show calculator input cursor fix: animate tab bar visibility refactor: apply naming agent rules refactor: isolate calculator numpad state fix: open app from calculator widget fix: flash calculator numpad errors refactor: extract private calculator numpad chore: update detekt command in docs fix: calculator numberpad bottom see-through refactor: extract MoneyType model fix: highlight calculator input bg instead of border fix: animate calculator pad collapse fix: remove calculator pad divider fix: center calculator os widget fix: refine calculator gestures fix: polish calculator input fix: dismiss calculator on outside tap fix: open calculator from os widget fix: align calculator preview color fix: group calculator fiat display fix: lock calculator widget scroll fix: hide tab bar during calculator input fix: normalize calculator fiat input fix: avoid grouped calculator fiat fix: use calculator number pad fix: show calculator widget preview fix: handle calculator keyboard fix: use calculator input masks --- AGENTS.md | 17 +- README.md | 2 +- app/src/main/AndroidManifest.xml | 13 + .../appwidget/AppWidgetPreferencesStore.kt | 4 + .../appwidget/AppWidgetRefreshWorker.kt | 7 + .../appwidget/CalculatorAppWidgetUpdater.kt | 21 + .../config/AppWidgetConfigActivity.kt | 3 + .../appwidget/config/AppWidgetConfigScreen.kt | 2 + .../config/AppWidgetConfigViewModel.kt | 2 + .../appwidget/model/AppWidgetPreferences.kt | 1 + .../ui/calculator/CalculatorGlanceContent.kt | 200 ++++++ .../ui/calculator/CalculatorGlanceReceiver.kt | 20 + .../ui/calculator/CalculatorGlanceWidget.kt | 86 +++ .../ui/components/GlanceWidgetScaffold.kt | 3 +- .../main/java/to/bitkit/models/MoneyType.kt | 6 + app/src/main/java/to/bitkit/ui/ContentView.kt | 20 +- .../main/java/to/bitkit/ui/MainActivity.kt | 24 +- .../java/to/bitkit/ui/components/NumberPad.kt | 22 +- .../java/to/bitkit/ui/components/Spacers.kt | 11 +- .../java/to/bitkit/ui/components/TabBar.kt | 251 ++++---- .../bitkit/ui/screens/wallets/HomeScreen.kt | 152 ++++- .../widgets/calculator/CalculatorDisplay.kt | 248 ++++++++ .../calculator/CalculatorPreviewScreen.kt | 208 +++---- .../widgets/calculator/CalculatorViewModel.kt | 305 +++++++++- .../calculator/components/CalculatorCard.kt | 568 ++++++++++++------ .../calculator/components/CalculatorInput.kt | 213 ++++--- .../BitcoinVisualTransformation.kt | 140 ----- .../CalculatorFormatter.kt | 56 -- .../MonetaryVisualTransformation.kt | 171 ------ .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 + .../appwidget_calculator_icon_background.xml | 5 + .../appwidget_calculator_row_background.xml | 6 + .../layout/appwidget_preview_calculator.xml | 94 +++ app/src/main/res/values/strings.xml | 1 + .../res/xml/appwidget_info_calculator.xml | 16 + .../calculator/CalculatorViewModelTest.kt | 251 ++++++++ .../components/CalculatorCardStateTest.kt | 87 +++ .../BitcoinVisualTransformationTest.kt | 64 -- changelog.d/next/937.added.md | 1 + 39 files changed, 2319 insertions(+), 984 deletions(-) create mode 100644 app/src/main/java/to/bitkit/appwidget/CalculatorAppWidgetUpdater.kt create mode 100644 app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceContent.kt create mode 100644 app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceReceiver.kt create mode 100644 app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceWidget.kt create mode 100644 app/src/main/java/to/bitkit/models/MoneyType.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt delete mode 100644 app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt delete mode 100644 app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt delete mode 100644 app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt create mode 100644 app/src/main/res/drawable/appwidget_calculator_icon_background.xml create mode 100644 app/src/main/res/drawable/appwidget_calculator_row_background.xml create mode 100644 app/src/main/res/layout/appwidget_preview_calculator.xml create mode 100644 app/src/main/res/xml/appwidget_info_calculator.xml create mode 100644 app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt delete mode 100644 app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt create mode 100644 changelog.d/next/937.added.md diff --git a/AGENTS.md b/AGENTS.md index d3f6b9d227..a163f592c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ GEO=false E2E=true ./gradlew assembleDevRelease E2E=true E2E_BACKEND=network ./gradlew assembleTnetRelease # Lint using detekt -./gradlew detekt +./gradlew detekt --rerun-tasks # Auto-format using detekt ./gradlew detekt --auto-correct @@ -133,6 +133,13 @@ suspend fun getData() = withContext(Dispatchers.IO) { } ## Common Patterns +### Naming: Use Context, Avoid Redundant Prefixes +- +- Infer names from the surrounding context: file name, component/class name, enclosing function, module, and call site. +- Avoid repeating domain prefixes or suffixes that are already obvious from the context, especially for private, nested, local and file-local symbols, i.e., inner-scoped parts invisible outside their context. +- Only add a prefix/suffix when it resolves real ambiguity at the call site. Preserve existing concise names unless there is a concrete readability or correctness reason to rename them. +- Examples: `Numpad` or `formatAmount`, instead of `CalculatorNumpad` or `formatCalculatorAmount`. + ### ViewModel State ```kotlin @@ -141,7 +148,7 @@ val uiState: StateFlow = _uiState.asStateFlow() fun updateState(action: Action) { viewModelScope.launch { - _uiState.update { it.copy(/* fields */) } + _uiState.update { it.copy(/*…*/) } } } ``` @@ -160,7 +167,6 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { ### Rules -- USE coding rules from `.cursor/default.rules.mdc` - ALWAYS run `./gradlew compileDevDebugKotlin` after code changes to verify code compiles - ALWAYS run `./gradlew testDevDebugUnitTest` after code changes to verify tests succeed and fix accordingly - ALWAYS run `./gradlew detekt` after code changes to check for new lint issues and fix accordingly @@ -201,7 +207,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS add trailing commas in multi-line declarations, EXCEPT after a `modifier = ...` last argument — never add a trailing comma there, whether the modifier is a single call (`modifier = Modifier.weight(1f)`) or a chain (`modifier = Modifier.fillMaxWidth().testTag("foo")`) - ALWAYS use `navController.navigateTo(route)` for simple navigation; NEVER use raw `navController.navigate(route)` — `navigateTo` prevents duplicate destinations - ALWAYS prefer `VerticalSpacer`, `HorizontalSpacer`, `FillHeight` and `FillWidth` over `Spacer` when applicable -- PREFER declaring small dependant classes, constants, interfaces or top-level functions in the same file with the core class where these are used +- PREFER declaring small dependant classes, constants, interfaces, or top-level functions in the same file with the core class where these are used - ALWAYS create data classes for state AFTER viewModel class in same file - ALWAYS return early where applicable, PREFER guard-like `if` conditions like `if (condition) return` - USE `docs/` as target dir of saved files when asked to create documentation for new features @@ -278,3 +284,6 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - Use `WakeNodeWorker` to manage the handling of remote notifications received via cloud messages - Use `*Services` to wrap rust library code exposed via bindings - Use CQRS pattern of Command + Handler like it's done in the `NotifyPaymentReceived` + `NotifyPaymentReceivedHandler` setup + +### Other Sources +- Use coding rules from `.cursor/default.rules.mdc` diff --git a/README.md b/README.md index 29c01e91d9..2c04f9aff9 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ The following IDE plugins are recommended for development with Android Studio or **Commands** ```sh -./gradlew detekt # run analysis + formatting check +./gradlew detekt --rerun-tasks # run analysis + formatting check (rerun flag ensures issues are always listed.) ./gradlew detekt --auto-correct # auto-fix formatting issues ``` Reports are generated in: `app/build/reports/detekt/`. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6f6d293f7d..ec4d6ffe97 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -256,6 +256,19 @@ android:resource="@xml/appwidget_info_weather" /> + + + + + + + + diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt index 1bcb54cc03..5b19ce3689 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt @@ -19,6 +19,8 @@ import to.bitkit.data.dto.WeatherDTO import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.serializers.AppWidgetDataSerializer +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.WidgetsRepo import javax.inject.Inject import javax.inject.Singleton @@ -32,6 +34,8 @@ private val Context.appWidgetDataStore: DataStore by dataStore( interface AppWidgetEntryPoint { fun appWidgetPreferencesStore(): AppWidgetPreferencesStore fun appWidgetDataRepository(): AppWidgetDataRepository + fun widgetsRepo(): WidgetsRepo + fun currencyRepo(): CurrencyRepo } @Singleton diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt index 20621a0cbe..1302bcbfc9 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt @@ -18,6 +18,8 @@ import dagger.assisted.AssistedInject import to.bitkit.appwidget.model.AppWidgetType import to.bitkit.appwidget.ui.blocks.BlocksGlanceReceiver import to.bitkit.appwidget.ui.blocks.BlocksGlanceWidget +import to.bitkit.appwidget.ui.calculator.CalculatorGlanceReceiver +import to.bitkit.appwidget.ui.calculator.CalculatorGlanceWidget import to.bitkit.appwidget.ui.facts.FactsGlanceReceiver import to.bitkit.appwidget.ui.facts.FactsGlanceWidget import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceReceiver @@ -74,6 +76,7 @@ class AppWidgetRefreshWorker @AssistedInject constructor( AppWidgetType.BLOCKS -> BlocksGlanceReceiver::class.java AppWidgetType.FACTS -> FactsGlanceReceiver::class.java AppWidgetType.WEATHER -> WeatherGlanceReceiver::class.java + AppWidgetType.CALCULATOR -> CalculatorGlanceReceiver::class.java } } @@ -133,6 +136,10 @@ class AppWidgetRefreshWorker @AssistedInject constructor( } WeatherGlanceWidget().updateAll(appContext) } + + AppWidgetType.CALCULATOR -> { + CalculatorGlanceWidget().updateAll(appContext) + } } } diff --git a/app/src/main/java/to/bitkit/appwidget/CalculatorAppWidgetUpdater.kt b/app/src/main/java/to/bitkit/appwidget/CalculatorAppWidgetUpdater.kt new file mode 100644 index 0000000000..64d45e51b0 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/CalculatorAppWidgetUpdater.kt @@ -0,0 +1,21 @@ +package to.bitkit.appwidget + +import android.content.Context +import androidx.glance.appwidget.updateAll +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.ui.calculator.CalculatorGlanceWidget +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CalculatorAppWidgetUpdater @Inject constructor( + @ApplicationContext private val context: Context, + private val preferencesStore: AppWidgetPreferencesStore, +) { + suspend fun update() { + if (!preferencesStore.hasWidgetsOfType(AppWidgetType.CALCULATOR).first()) return + CalculatorGlanceWidget().updateAll(context) + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt index 976a32c9e8..e09bc6a3e7 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt @@ -13,6 +13,7 @@ import to.bitkit.appwidget.AppWidgetRefreshWorker import to.bitkit.appwidget.model.AppWidgetType import to.bitkit.appwidget.ui.blocks.BlocksGlanceReceiver import to.bitkit.appwidget.ui.blocks.BlocksGlanceWidget +import to.bitkit.appwidget.ui.calculator.CalculatorGlanceReceiver import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceReceiver import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceWidget import to.bitkit.appwidget.ui.price.PriceGlanceReceiver @@ -62,6 +63,7 @@ class AppWidgetConfigActivity : ComponentActivity() { AppWidgetType.BLOCKS -> BlocksGlanceWidget().updateAll(this@AppWidgetConfigActivity) AppWidgetType.FACTS -> Unit AppWidgetType.WEATHER -> WeatherGlanceWidget().updateAll(this@AppWidgetConfigActivity) + AppWidgetType.CALCULATOR -> Unit } AppWidgetRefreshWorker.enqueue(this@AppWidgetConfigActivity) val result = Intent().putExtra( @@ -89,6 +91,7 @@ class AppWidgetConfigActivity : ComponentActivity() { HeadlinesGlanceReceiver::class.java.name -> AppWidgetType.HEADLINES BlocksGlanceReceiver::class.java.name -> AppWidgetType.BLOCKS WeatherGlanceReceiver::class.java.name -> AppWidgetType.WEATHER + CalculatorGlanceReceiver::class.java.name -> AppWidgetType.CALCULATOR else -> { Logger.warn( "Encountered unknown provider class '$providerClass' " + diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt index 718f965f50..e3710dc693 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt @@ -48,6 +48,8 @@ fun AppWidgetConfigScreen( AppWidgetType.FACTS -> Unit + AppWidgetType.CALCULATOR -> Unit + AppWidgetType.WEATHER -> WeatherConfigContent( state = state, onSelectOption = { viewModel.selectWeatherOption(it) }, diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt index 3a29d30f45..34b3bf6c80 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt @@ -169,6 +169,7 @@ class AppWidgetConfigViewModel @Inject constructor( AppWidgetType.BLOCKS -> it.copy(blocksPreferences = BlocksPreferences()) AppWidgetType.FACTS -> it AppWidgetType.WEATHER -> it.copy(weatherPreferences = WeatherPreferences()) + AppWidgetType.CALCULATOR -> it } } } @@ -184,6 +185,7 @@ class AppWidgetConfigViewModel @Inject constructor( AppWidgetType.BLOCKS -> saveBlocks(state) AppWidgetType.FACTS -> Unit AppWidgetType.WEATHER -> saveWeather(state) + AppWidgetType.CALCULATOR -> Unit } onComplete() diff --git a/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt index 1fffc48923..55ad17778a 100644 --- a/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt +++ b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt @@ -16,6 +16,7 @@ enum class AppWidgetType { BLOCKS, FACTS, WEATHER, + CALCULATOR, } @Stable diff --git a/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceContent.kt b/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceContent.kt new file mode 100644 index 0000000000..2f1310822e --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceContent.kt @@ -0,0 +1,200 @@ +package to.bitkit.appwidget.ui.calculator + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.WidthModifier +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.Text +import androidx.glance.unit.Dimension +import to.bitkit.appwidget.ui.components.GlanceLayoutDimens +import to.bitkit.appwidget.ui.components.GlanceWidgetScaffold +import to.bitkit.appwidget.ui.components.HorizontalSpacer +import to.bitkit.appwidget.ui.components.VerticalSpacer +import to.bitkit.appwidget.ui.theme.GlanceColors +import to.bitkit.appwidget.ui.theme.GlanceTextStyles +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.MainActivity +import to.bitkit.ui.screens.widgets.calculator.components.toCalculatorDisplaySymbol +import to.bitkit.ui.screens.widgets.calculator.formatBitcoinValue +import to.bitkit.ui.screens.widgets.calculator.formatFiatValue +import to.bitkit.ui.theme.Colors +import java.util.Locale + +private val ROW_RADIUS = 8.dp +private val WIDE_ICON_SIZE = 32.dp +private val COMPACT_ICON_SIZE = 24.dp +private val RowBackground = ColorProvider(day = Colors.Black, night = Colors.Black) +private val IconBackground = ColorProvider(day = Colors.Gray6, night = Colors.Gray6) +private val BrandColor = ColorProvider(day = Colors.Brand, night = Colors.Brand) + +@Suppress("RestrictedApi") +@Composable +fun CalculatorGlanceContent( + values: CalculatorValues, + currencyState: CurrencyState, +) { + val context = LocalContext.current + val btcValue = formatBitcoinValue(values.btcValue, currencyState.displayUnit) + val fiatValue = formatFiatValue(values.fiatValue) + val openCalculatorIntent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + + GlanceWidgetScaffold(onClick = actionStartActivity(openCalculatorIntent)) { + if (LocalSize.current.width >= GlanceLayoutDimens.WIDE_LAYOUT_MIN_WIDTH) { + WideContent( + btcValue = btcValue, + fiatValue = fiatValue, + currencySymbol = currencyState.currencySymbol, + selectedCurrency = currencyState.selectedCurrency, + ) + } else { + CompactContent( + btcValue = btcValue, + fiatValue = fiatValue, + currencySymbol = currencyState.currencySymbol, + ) + } + } +} + +@Composable +private fun WideContent( + btcValue: String, + fiatValue: String, + currencySymbol: String, + selectedCurrency: String, +) { + Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier.fillMaxSize() + ) { + Column(modifier = GlanceModifier.fillMaxWidth()) { + CalculatorRow( + currencySymbol = BITCOIN_SYMBOL, + value = btcValue, + label = "BITCOIN", + iconSize = WIDE_ICON_SIZE, + rowPadding = 16.dp, + modifier = GlanceModifier.fillMaxWidth() + ) + VerticalSpacer(16.dp) + CalculatorRow( + currencySymbol = currencySymbol, + value = fiatValue, + label = selectedCurrency.uppercase(Locale.ENGLISH), + iconSize = WIDE_ICON_SIZE, + rowPadding = 16.dp, + modifier = GlanceModifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun CompactContent( + btcValue: String, + fiatValue: String, + currencySymbol: String, +) { + Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier.fillMaxSize() + ) { + Column(modifier = GlanceModifier.fillMaxWidth()) { + CalculatorRow( + currencySymbol = BITCOIN_SYMBOL, + value = btcValue, + label = null, + iconSize = COMPACT_ICON_SIZE, + rowPadding = 12.dp, + modifier = GlanceModifier.fillMaxWidth() + ) + VerticalSpacer(16.dp) + CalculatorRow( + currencySymbol = currencySymbol, + value = fiatValue, + label = null, + iconSize = COMPACT_ICON_SIZE, + rowPadding = 12.dp, + modifier = GlanceModifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun CalculatorRow( + currencySymbol: String, + value: String, + label: String?, + iconSize: Dp, + rowPadding: Dp, + modifier: GlanceModifier = GlanceModifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .background(RowBackground) + .cornerRadius(ROW_RADIUS) + .padding(rowPadding) + ) { + CalculatorIcon( + currencySymbol = currencySymbol, + size = iconSize, + ) + HorizontalSpacer(8.dp) + Text( + text = value, + style = GlanceTextStyles.bodySSB, + maxLines = 1, + modifier = GlanceModifier.then(WidthModifier(Dimension.Expand)) + ) + if (label != null) { + HorizontalSpacer(8.dp) + Text( + text = label, + style = GlanceTextStyles.captionB.copy(color = GlanceColors.textSecondary), + maxLines = 1, + ) + } + } +} + +@Composable +private fun CalculatorIcon( + currencySymbol: String, + size: Dp, +) { + Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier + .size(size) + .background(IconBackground) + .cornerRadius(size / 2) + ) { + Text( + text = currencySymbol.toCalculatorDisplaySymbol(), + style = GlanceTextStyles.bodySSB.copy(color = BrandColor), + maxLines = 1, + ) + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceReceiver.kt new file mode 100644 index 0000000000..1a25f6e8e5 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceReceiver.kt @@ -0,0 +1,20 @@ +package to.bitkit.appwidget.ui.calculator + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import to.bitkit.appwidget.AppWidgetRefreshWorker + +class CalculatorGlanceReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = CalculatorGlanceWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + AppWidgetRefreshWorker.enqueue(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + AppWidgetRefreshWorker.cancelIfNoWidgets(context) + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceWidget.kt b/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceWidget.kt new file mode 100644 index 0000000000..4d7d4b3b12 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/calculator/CalculatorGlanceWidget.kt @@ -0,0 +1,86 @@ +package to.bitkit.appwidget.ui.calculator + +import android.content.Context +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.flow.first +import to.bitkit.appwidget.AppWidgetEntryPoint +import to.bitkit.appwidget.AppWidgetRefreshWorker +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.ui.components.GlanceLayoutDimens +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.screens.widgets.calculator.calculatorBtcValueToSats +import to.bitkit.ui.screens.widgets.calculator.isZeroBtcValue + +class CalculatorGlanceWidget : GlanceAppWidget() { + + override val sizeMode = SizeMode.Responsive( + setOf(GlanceLayoutDimens.COMPACT_WIDGET_SIZE, GlanceLayoutDimens.WIDE_WIDGET_SIZE), + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val accessor = EntryPointAccessors + .fromApplication(context, AppWidgetEntryPoint::class.java) + val store = accessor.appWidgetPreferencesStore() + val widgetsRepo = accessor.widgetsRepo() + val currencyRepo = accessor.currencyRepo() + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + + val current = store.data.first() + val isRegistered = current.entries.any { it.appWidgetId == appWidgetId } + if (!isRegistered) { + store.registerWidget(appWidgetId, AppWidgetType.CALCULATOR) + AppWidgetRefreshWorker.enqueue(context) + } + + provideContent { + val widgetsData by widgetsRepo.widgetsDataFlow.collectAsState() + val currencyState by currencyRepo.currencyState.collectAsState() + val calculatorValues = remember(widgetsData.calculatorValues, currencyState) { + resolveCalculatorValues( + values = widgetsData.calculatorValues, + currencyState = currencyState, + currencyRepo = currencyRepo, + ) + } + + CalculatorGlanceContent( + values = calculatorValues, + currencyState = currencyState, + ) + } + } + + override suspend fun onDelete(context: Context, glanceId: GlanceId) { + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(glanceId) + EntryPointAccessors + .fromApplication(context, AppWidgetEntryPoint::class.java) + .appWidgetPreferencesStore() + .unregisterWidget(appWidgetId) + } + + private fun resolveCalculatorValues( + values: CalculatorValues, + currencyState: CurrencyState, + currencyRepo: CurrencyRepo, + ): CalculatorValues { + if (values.btcValue.isEmpty() || isZeroBtcValue(values.btcValue, currencyState.displayUnit)) { + return values + } + + val sats = calculatorBtcValueToSats(values.btcValue, currencyState.displayUnit) + val fiatValue = currencyRepo.convertSatsToFiat(sats).getOrNull()?.formatted.orEmpty() + if (fiatValue.isEmpty()) return values + + return values.copy(fiatValue = fiatValue) + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceWidgetScaffold.kt b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceWidgetScaffold.kt index e1b8d8d741..7cc8688a8c 100644 --- a/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceWidgetScaffold.kt +++ b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceWidgetScaffold.kt @@ -15,11 +15,12 @@ import to.bitkit.R @Composable fun GlanceWidgetScaffold( onClick: Action? = null, + backgroundProvider: ImageProvider = ImageProvider(R.drawable.appwidget_background), content: @Composable () -> Unit, ) { val modifier = GlanceModifier .fillMaxSize() - .background(ImageProvider(R.drawable.appwidget_background)) + .background(backgroundProvider) .padding(16.dp) .let { mod -> if (onClick != null) mod.clickable(onClick) else mod diff --git a/app/src/main/java/to/bitkit/models/MoneyType.kt b/app/src/main/java/to/bitkit/models/MoneyType.kt new file mode 100644 index 0000000000..0d0a716086 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/MoneyType.kt @@ -0,0 +1,6 @@ +package to.bitkit.models + +enum class MoneyType { + BITCOIN, + FIAT, +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4b2d36b749..a8112743cf 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -496,6 +496,8 @@ fun ContentView( } ) { Box(modifier = Modifier.fillMaxSize()) { + var isHomeCalculatorInputActive by remember { mutableStateOf(false) } + RootNavHost( navController = navController, drawerState = drawerState, @@ -505,6 +507,7 @@ fun ContentView( settingsViewModel = settingsViewModel, currencyViewModel = currencyViewModel, transferViewModel = transferViewModel, + onHomeCalculatorInputActiveChanged = { isHomeCalculatorInputActive = it }, ) val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -515,9 +518,18 @@ fun ContentView( Routes.Savings::class.qualifiedName, Routes.Spending::class.qualifiedName, ) + val hideTabBarForCalculator = + currentRoute == Routes.Home::class.qualifiedName && isHomeCalculatorInputActive + + LaunchedEffect(currentRoute) { + if (currentRoute != Routes.Home::class.qualifiedName) { + isHomeCalculatorInputActive = false + } + } if (showTabBar) { TabBar( + isVisible = !hideTabBarForCalculator, onSendClick = { appViewModel.showSheet(Sheet.Send()) }, onReceiveClick = { appViewModel.showSheet(Sheet.Receive()) }, onScanClick = { appViewModel.showSheet(Sheet.Send(SendRoute.QrScanner)) }, @@ -556,6 +568,7 @@ private fun RootNavHost( settingsViewModel: SettingsViewModel, currencyViewModel: CurrencyViewModel, transferViewModel: TransferViewModel, + onHomeCalculatorInputActiveChanged: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() @@ -567,6 +580,7 @@ private fun RootNavHost( settingsViewModel = settingsViewModel, navController = navController, drawerState = drawerState, + onCalculatorInputActiveChanged = onHomeCalculatorInputActiveChanged, ) allActivity( activityListViewModel = activityListViewModel, @@ -593,7 +607,7 @@ private fun RootNavHost( logs(navController) suggestions(navController) support(navController) - widgets(navController, settingsViewModel, currencyViewModel) + widgets(navController, settingsViewModel) update() recoveryMode(navController, appViewModel) @@ -810,6 +824,7 @@ private fun NavGraphBuilder.home( settingsViewModel: SettingsViewModel, navController: NavHostController, drawerState: DrawerState, + onCalculatorInputActiveChanged: (Boolean) -> Unit, ) { composable { val isRefreshing by walletViewModel.isRefreshing.collectAsStateWithLifecycle() @@ -836,6 +851,7 @@ private fun NavGraphBuilder.home( walletViewModel = walletViewModel, appViewModel = appViewModel, activityListViewModel = activityListViewModel, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } } @@ -1461,7 +1477,6 @@ private fun NavGraphBuilder.support( private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, - currencyViewModel: CurrencyViewModel, ) { composableWithDefaultTransitions { WidgetsIntroScreen( @@ -1504,7 +1519,6 @@ private fun NavGraphBuilder.widgets( CalculatorPreviewScreen( onClose = { navController.navigateToHome() }, onBack = { navController.popBackStack() }, - currencyViewModel = currencyViewModel ) } navigationWithDefaultTransitions( diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 12870a400f..14dd51edf1 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -64,7 +64,7 @@ import to.bitkit.viewmodels.WalletViewModel @AndroidEntryPoint class MainActivity : FragmentActivity() { private companion object { - const val KEY_CONSUMED_DEEPLINK_URI = "consumed_deeplink_uri" + const val KEY_CONSUMED_LAUNCH_INTENT = "consumed_launch_intent" } private val appViewModel by viewModels() @@ -76,7 +76,7 @@ class MainActivity : FragmentActivity() { private val settingsViewModel by viewModels() private val backupsViewModel by viewModels() - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -88,10 +88,10 @@ class MainActivity : FragmentActivity() { importance = NotificationManager.IMPORTANCE_LOW ) - val consumedUri = savedInstanceState?.getString(KEY_CONSUMED_DEEPLINK_URI) - val currentUri = intent?.data?.toString() - if (currentUri == null || currentUri != consumedUri) { - appViewModel.handleDeeplinkIntent(intent) + val consumedLaunchIntent = savedInstanceState?.getString(KEY_CONSUMED_LAUNCH_INTENT) + val currentLaunchIntent = intent.launchKey() + if (currentLaunchIntent == null || currentLaunchIntent != consumedLaunchIntent) { + appViewModel.handleLaunchIntent(intent) } installSplashScreen() @@ -207,12 +207,12 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - appViewModel.handleDeeplinkIntent(intent) + appViewModel.handleLaunchIntent(intent) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - intent?.data?.toString()?.let { outState.putString(KEY_CONSUMED_DEEPLINK_URI, it) } + intent.launchKey()?.let { outState.putString(KEY_CONSUMED_LAUNCH_INTENT, it) } } override fun onDestroy() { @@ -237,6 +237,14 @@ class MainActivity : FragmentActivity() { } } +private fun Intent?.launchKey(): String? { + this ?: return null + return when (action) { + Intent.ACTION_VIEW -> data?.toString() + else -> null + } +} + @Composable private fun OnboardingNav( startupNavController: NavHostController, diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt index 240ef62e20..e5a49c2b6f 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -71,13 +72,20 @@ fun NumberPad( modifier: Modifier = Modifier, type: NumberPadType = NumberPadType.SIMPLE, availableHeight: Dp = defaultHeight, + decimalSeparator: String = KEY_DECIMAL, errorKey: String? = null, + includeNavigationBarsPadding: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } + val safeAreaModifier = if (includeNavigationBarsPadding) { + modifier.navigationBarsPadding() + } else { + modifier + } BoxWithConstraints( - modifier = modifier + modifier = safeAreaModifier .focusRequester(focusRequester) .onPreviewKeyEvent { keyEvent -> if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false @@ -124,9 +132,10 @@ fun NumberPad( ) NumberPadType.DECIMAL -> NumberPadKeyButton( - text = KEY_DECIMAL, + text = decimalSeparator, onPress = onPress, height = buttonHeight, + key = KEY_DECIMAL, hasError = errorKey == KEY_DECIMAL, testTag = "NDecimal", ) @@ -161,6 +170,8 @@ fun NumberPad( currencies: CurrencyState = LocalCurrencies.current, type: NumberPadType = viewModel.getNumberPadType(currencies), availableHeight: Dp = defaultHeight, + decimalSeparator: String = KEY_DECIMAL, + includeNavigationBarsPadding: Boolean = false, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() NumberPad( @@ -168,7 +179,9 @@ fun NumberPad( modifier = modifier, type = type, availableHeight = availableHeight, + decimalSeparator = decimalSeparator, errorKey = uiState.errorKey, + includeNavigationBarsPadding = includeNavigationBarsPadding, ) } @@ -186,7 +199,7 @@ private val hardwareKeyMap = mapOf( Key.Eight to "8", Key.NumPad8 to "8", Key.Nine to "9", Key.NumPad9 to "9", Key.Backspace to KEY_DELETE, Key.Delete to KEY_DELETE, - Key.Period to KEY_DECIMAL, Key.NumPadDot to KEY_DECIMAL, + Key.Period to KEY_DECIMAL, Key.NumPadDot to KEY_DECIMAL, Key.Comma to KEY_DECIMAL, ) private fun mapHardwareKey(key: Key, type: NumberPadType): String? { @@ -201,11 +214,12 @@ fun NumberPadKeyButton( onPress: (String) -> Unit, height: Dp, modifier: Modifier = Modifier, + key: String = text, hasError: Boolean = false, testTag: String = "N$text", ) { NumberPadKey( - onClick = { onPress(text) }, + onClick = { onPress(key) }, height = height, haptic = if (hasError) errorHaptic else pressHaptic, modifier = modifier.testTag(testTag), diff --git a/app/src/main/java/to/bitkit/ui/components/Spacers.kt b/app/src/main/java/to/bitkit/ui/components/Spacers.kt index 8d54132a16..3c07e477d0 100644 --- a/app/src/main/java/to/bitkit/ui/components/Spacers.kt +++ b/app/src/main/java/to/bitkit/ui/components/Spacers.kt @@ -58,13 +58,20 @@ fun RowScope.FillWidth( @Composable fun StatusBarSpacer(modifier: Modifier = Modifier) { Spacer( - modifier = modifier.height(Insets.Top), + modifier = modifier.height(Insets.Top) ) } @Composable fun TopBarSpacer(modifier: Modifier = Modifier) { Spacer( - modifier = modifier.height(TopBarHeight), + modifier = modifier.height(TopBarHeight) + ) +} + +@Composable +fun NavBarSpacer(modifier: Modifier = Modifier) { + Spacer( + modifier = modifier.height(Insets.Bottom) ) } diff --git a/app/src/main/java/to/bitkit/ui/components/TabBar.kt b/app/src/main/java/to/bitkit/ui/components/TabBar.kt index 1f0c22dd51..e9954f42ee 100644 --- a/app/src/main/java/to/bitkit/ui/components/TabBar.kt +++ b/app/src/main/java/to/bitkit/ui/components/TabBar.kt @@ -1,6 +1,14 @@ package to.bitkit.ui.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -24,17 +32,14 @@ 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.draw.drawWithContent import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -52,161 +57,149 @@ private val iconSize = 20.dp const val TAB_BAR_HEIGHT = 56 const val TAB_BAR_PADDING_BOTTOM = 8 private const val GRADIENT_HEIGHT = 134 +private const val TAB_BAR_EXIT_DURATION_MS = 180 +private const val TAB_BAR_SETTLE_DAMPING_RATIO = 0.8f private val buttonLeftShape = RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50) private val buttonRightShape = RoundedCornerShape(topEndPercent = 50, bottomEndPercent = 50) +private val tabBarEnterOffsetSpring = spring( + dampingRatio = TAB_BAR_SETTLE_DAMPING_RATIO, + stiffness = Spring.StiffnessMediumLow, +) +private val tabBarExitOffsetTween = tween( + durationMillis = TAB_BAR_EXIT_DURATION_MS, + easing = FastOutSlowInEasing, +) @OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun BoxScope.TabBar( modifier: Modifier = Modifier, + isVisible: Boolean = true, onSendClick: () -> Unit = {}, onReceiveClick: () -> Unit = {}, onScanClick: () -> Unit = {}, ) { - Box( + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tabBarEnterOffsetSpring, + ), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tabBarExitOffsetTween, + ), modifier = modifier .align(Alignment.BottomCenter) - .fillMaxWidth() + .fillMaxWidth(), ) { Box( modifier = Modifier - .align(Alignment.BottomCenter) .fillMaxWidth() - .height(GRADIENT_HEIGHT.dp) - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, Colors.Black), - ) - ) - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = TAB_BAR_PADDING_BOTTOM.dp) - .navigationBarsPadding() ) { - Row( - modifier = Modifier.primaryButtonStyle( - isEnabled = true, - shape = MaterialTheme.shapes.large, - ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(GRADIENT_HEIGHT.dp) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Colors.Black), + ) + ) + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = TAB_BAR_PADDING_BOTTOM.dp) + .navigationBarsPadding() ) { - // Send Button - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1f) - .height(TAB_BAR_HEIGHT.dp) - .clip(buttonLeftShape) - .clickableAlpha(ripple = true) { onSendClick() } - .testTag("Send") + Row( + modifier = Modifier.primaryButtonStyle( + isEnabled = true, + shape = MaterialTheme.shapes.large, + ) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.ArrowUpward, - contentDescription = stringResource(R.string.wallet__send), - modifier = Modifier.size(iconSize) - ) - Spacer(Modifier.width(iconToTextGap)) - BodySSB(text = stringResource(R.string.wallet__send)) + // Send Button + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .height(TAB_BAR_HEIGHT.dp) + .clip(buttonLeftShape) + .clickableAlpha(ripple = true, enabled = isVisible) { onSendClick() } + .testTag("Send") + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = stringResource(R.string.wallet__send), + modifier = Modifier.size(iconSize) + ) + Spacer(Modifier.width(iconToTextGap)) + BodySSB(text = stringResource(R.string.wallet__send)) + } + } + + // Receive Button + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .height(TAB_BAR_HEIGHT.dp) + .clip(buttonRightShape) + .clickableAlpha(ripple = true, enabled = isVisible) { onReceiveClick() } + .testTag("Receive") + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = stringResource(R.string.wallet__receive), + modifier = Modifier.size(iconSize) + ) + Spacer(Modifier.width(iconToTextGap)) + BodySSB(text = stringResource(R.string.wallet__receive)) + } } } - // Receive Button + // Scan button Box( contentAlignment = Alignment.Center, modifier = Modifier - .weight(1f) - .height(TAB_BAR_HEIGHT.dp) - .clip(buttonRightShape) - .clickableAlpha(ripple = true) { onReceiveClick() } - .testTag("Receive") - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.ArrowDownward, - contentDescription = stringResource(R.string.wallet__receive), - modifier = Modifier.size(iconSize) + .size(64.dp) + .shadow( + elevation = 25.dp, + shape = CircleShape, + ambientColor = Colors.Black25, + spotColor = Colors.Black25 ) - Spacer(Modifier.width(iconToTextGap)) - BodySSB(text = stringResource(R.string.wallet__receive)) - } - } - } - - // Scan button - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(64.dp) - // Shadow 1: gray2 shadow with radius 0 at y=-1 (top highlight) - .drawWithContent { - // Draw a prominent top highlight - drawCircle( - color = Colors.Gray2, - radius = size.width / 2, - center = Offset(size.width / 2, size.height / 2 - 1.5.dp.toPx()), - alpha = 0.6f - ) - drawContent() - } - // Shadow 2: black 25% opacity, radius 25, y offset 20 - .shadow( - elevation = 25.dp, - shape = CircleShape, - ambientColor = Colors.Black25, - spotColor = Colors.Black25 - ) - .clip(CircleShape) - .background(Colors.Gray7) - // Overlay: Circle strokeBorder with linear gradient mask (iOS: .mask) - .drawWithContent { - drawContent() - - // The mask gradient goes from black (visible) at top to clear (invisible) at bottom - val borderWidth = 2.dp.toPx() - - // Create vertical gradient mask (black to clear) - val maskGradient = Brush.verticalGradient( - colors = listOf( - Color.White, // Top: full opacity (shows border) - Color.Transparent // Bottom: transparent (hides border) + .clip(CircleShape) + .background(Colors.Gray7) + .border( + width = 2.dp, + brush = Brush.verticalGradient( + colors = listOf( + Colors.Gray2.copy(alpha = 0.6f), + Color.Transparent, + ), ), - startY = 0f, - endY = size.height - ) - - // Draw solid black circular border first, then apply gradient as alpha mask - drawCircle( - color = Color.Black, - radius = (size.width - borderWidth) / 2, - center = Offset(size.width / 2, size.height / 2), - style = Stroke(width = borderWidth), - alpha = 1f - ) - - // Apply gradient mask by drawing gradient as overlay with BlendMode - drawCircle( - brush = maskGradient, - radius = (size.width - borderWidth) / 2, - center = Offset(size.width / 2, size.height / 2), - style = Stroke(width = borderWidth), - blendMode = BlendMode.DstIn + shape = CircleShape, ) - } - .clickableAlpha(ripple = true) { onScanClick() } - .testTag("Scan") - ) { - Icon( - painter = painterResource(R.drawable.ic_scan), - contentDescription = stringResource(R.string.wallet__recipient_scan), - tint = Colors.Gray1, - modifier = Modifier.size(22.dp) - ) + .clickableAlpha(ripple = true, enabled = isVisible) { onScanClick() } + .testTag("Scan") + ) { + Icon( + painter = painterResource(R.drawable.ic_scan), + contentDescription = stringResource(R.string.wallet__recipient_scan), + tint = Colors.Gray1, + modifier = Modifier.size(22.dp) + ) + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index df31511194..880e29c8be 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -19,7 +21,7 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars @@ -48,14 +50,26 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +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.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -118,7 +132,6 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView -import to.bitkit.ui.currencyViewModel import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToActivityItem import to.bitkit.ui.navigateToAllActivity @@ -167,6 +180,7 @@ fun HomeScreen( walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, + onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, homeViewModel: HomeViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -322,6 +336,7 @@ fun HomeScreen( onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -352,15 +367,30 @@ private fun Content( onNavigateToActivityItem: (String) -> Unit = {}, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, + onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, hazeState: HazeState = rememberHazeState(), balances: BalanceState = LocalBalances.current, ) { val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var calculatorInputDismissKey by remember { mutableIntStateOf(0) } + var isCalculatorInputActive by remember { mutableStateOf(false) } + val onCalculatorInputActiveChange = { isActive: Boolean -> + isCalculatorInputActive = isActive + onCalculatorInputActiveChanged(isActive) + } val pageCount = if (homeUiState.showWidgets) 2 else 1 val pagerState = rememberPagerState( initialPage = homeUiState.currentPage, pageCount = { pageCount }, ) + val dismissKeyboard = { + focusManager.clearFocus(force = true) + keyboardController?.hide() + calculatorInputDismissKey++ + Unit + } LaunchedEffect(pagerState.currentPage) { onPageChanged(pagerState.currentPage) @@ -369,6 +399,12 @@ private fun Content( } } + LaunchedEffect(pagerState.currentPage, isCalculatorInputActive) { + if (pagerState.currentPage == 0 && isCalculatorInputActive) { + dismissKeyboard() + } + } + val density = LocalDensity.current val screenHeightDp = with(density) { LocalWindowInfo.current.containerSize.height.toDp().value.toInt() } val isSmallScreen = screenHeightDp < SMALL_SCREEN_HEIGHT_DP @@ -385,12 +421,24 @@ private fun Content( hazeState = hazeState, profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, - onClickProfile = onClickProfile, + onClickProfile = { + dismissKeyboard() + onClickProfile() + }, showEditWidgets = homeUiState.currentPage == 1 && homeUiState.showWidgets, isEditingWidgets = homeUiState.isEditingWidgets, - onClickEditWidgetList = onClickEditWidgetList, - onNavigateToAppStatus = onNavigateToAppStatus, - onOpenDrawer = { scope.launch { drawerState.open() } }, + onClickEditWidgetList = { + dismissKeyboard() + onClickEditWidgetList() + }, + onNavigateToAppStatus = { + dismissKeyboard() + onNavigateToAppStatus() + }, + onOpenDrawer = { + dismissKeyboard() + scope.launch { drawerState.open() } + }, ) VerticalPager( @@ -407,7 +455,6 @@ private fun Content( ), modifier = Modifier .fillMaxSize() - .imePadding() .hazeSource(state = hazeState) .zIndex(0f) ) { page -> @@ -428,6 +475,10 @@ private fun Content( 1 -> WidgetsPage( homeUiState = homeUiState, + calculatorInputDismissKey = calculatorInputDismissKey, + isCalculatorInputActive = isCalculatorInputActive, + onDismissCalculatorInput = dismissKeyboard, + onCalculatorInputActiveChanged = onCalculatorInputActiveChange, onRemoveSuggestion = onRemoveSuggestion, onClickSuggestion = onClickSuggestion, onClickAddWidget = onClickAddWidget, @@ -596,6 +647,10 @@ private fun BalancesSection( @Composable private fun WidgetsPage( homeUiState: HomeUiState, + calculatorInputDismissKey: Int, + isCalculatorInputActive: Boolean, + onDismissCalculatorInput: () -> Unit, + onCalculatorInputActiveChanged: (Boolean) -> Unit, onRemoveSuggestion: (Suggestion) -> Unit, onClickSuggestion: (Suggestion) -> Unit, onClickAddWidget: () -> Unit, @@ -603,12 +658,36 @@ private fun WidgetsPage( onClickDeleteWidget: (WidgetType) -> Unit, onMoveWidget: (Int, Int) -> Unit, ) { - Box(modifier = Modifier.fillMaxSize()) { + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + var pageBounds by remember { mutableStateOf(null) } + var calculatorBounds by remember { mutableStateOf(null) } + + LaunchedEffect(homeUiState.widgetsWithPosition, homeUiState.isEditingWidgets) { + val hasCalculator = homeUiState.widgetsWithPosition.any { it.type == WidgetType.CALCULATOR } + if (homeUiState.isEditingWidgets || !hasCalculator) { + calculatorBounds = null + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { pageBounds = it.boundsInRoot() } + .dismissCalculatorInputOnOutsideTap( + isCalculatorInputActive = isCalculatorInputActive, + pageBounds = pageBounds, + calculatorBounds = calculatorBounds, + onDismiss = onDismissCalculatorInput, + ) + ) { Column( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll( + state = rememberScrollState(), + enabled = !isCalculatorInputActive, + ) ) { StatusBarSpacer() TopBarSpacer() @@ -633,6 +712,9 @@ private fun WidgetsPage( } else { Widgets( homeUiState = homeUiState, + calculatorInputDismissKey = calculatorInputDismissKey, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, + onCalculatorBoundsChanged = { calculatorBounds = it }, onRemoveSuggestion = onRemoveSuggestion, onClickSuggestion = onClickSuggestion, ) @@ -646,11 +728,44 @@ private fun WidgetsPage( modifier = Modifier.testTag("WidgetsAdd") ) - VerticalSpacer(150.dp) + VerticalSpacer(150.dp + imeBottomPadding) } } } +private fun Modifier.dismissCalculatorInputOnOutsideTap( + isCalculatorInputActive: Boolean, + pageBounds: Rect?, + calculatorBounds: Rect?, + onDismiss: () -> Unit, +): Modifier = pointerInput(isCalculatorInputActive, pageBounds, calculatorBounds) { + if (!isCalculatorInputActive) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val page = pageBounds ?: return@awaitEachGesture + val calculator = calculatorBounds ?: return@awaitEachGesture + val tapPositionInRoot = down.position.toRootPosition(page) + var isTap = true + var isPointerUp = false + + while (!isPointerUp) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val pointer = event.changes.firstOrNull { it.id == down.id } ?: return@awaitEachGesture + if ((pointer.position - down.position).getDistance() > viewConfiguration.touchSlop) { + isTap = false + } + isPointerUp = pointer.changedToUpIgnoreConsumed() + } + + if (isTap && !calculator.contains(tapPositionInRoot)) { + onDismiss() + } + } +} + +private fun Offset.toRootPosition(bounds: Rect): Offset = this + Offset(bounds.left, bounds.top) + @Composable private fun SuggestionsSection( suggestions: ImmutableList, @@ -720,6 +835,9 @@ private fun WidgetsOnboardingHint(modifier: Modifier = Modifier) { @Composable private fun Widgets( homeUiState: HomeUiState, + calculatorInputDismissKey: Int, + onCalculatorInputActiveChanged: (Boolean) -> Unit, + onCalculatorBoundsChanged: (Rect) -> Unit, onRemoveSuggestion: (Suggestion) -> Unit, onClickSuggestion: (Suggestion) -> Unit, ) { @@ -754,13 +872,13 @@ private fun Widgets( } WidgetType.CALCULATOR -> { - currencyViewModel?.let { - CalculatorCard( - currencyViewModel = it, - showWidgetTitle = homeUiState.showWidgetTitles, - modifier = Modifier.fillMaxWidth() - ) - } + CalculatorCard( + dismissNumberPadKey = calculatorInputDismissKey, + onInputActiveChange = onCalculatorInputActiveChanged, + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { onCalculatorBoundsChanged(it.boundsInRoot()) } + ) } WidgetType.FACTS -> { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt new file mode 100644 index 0000000000..bb0f6d0981 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt @@ -0,0 +1,248 @@ +package to.bitkit.ui.screens.widgets.calculator + +import to.bitkit.ext.removeSpaces +import to.bitkit.ext.toLongOrDefault +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CLASSIC_DECIMALS +import to.bitkit.models.DECIMAL_SEPARATOR +import to.bitkit.models.FIAT_GROUPING_SEPARATOR +import to.bitkit.models.SATS_GROUPING_SEPARATOR +import to.bitkit.models.SATS_IN_BTC +import to.bitkit.models.asBtc +import to.bitkit.models.formatCurrency +import to.bitkit.ui.components.KEY_000 +import to.bitkit.ui.components.KEY_DECIMAL +import to.bitkit.ui.components.KEY_DELETE +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormatSymbols +import java.util.Locale + +internal fun calculatorBtcValueToSats( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Long { + val satsOrBtc = btcValue.removeSpaces() + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> satsOrBtc.toLongOrDefault() + BitcoinDisplayUnit.CLASSIC -> { + val btcDecimal = satsOrBtc.toBigDecimalOrNull() ?: BigDecimal.ZERO + val satsDecimal = btcDecimal.multiply(BigDecimal(SATS_IN_BTC)) + val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) + roundedNumber.toLong() + } + } +} + +internal fun convertCalculatorBtcValueUnit( + btcValue: String, + fromDisplayUnit: BitcoinDisplayUnit, + toDisplayUnit: BitcoinDisplayUnit, +): String { + if (btcValue.isEmpty() || fromDisplayUnit == toDisplayUnit) return btcValue + + val sats = calculatorBtcValueToSats(btcValue, fromDisplayUnit) + return when (toDisplayUnit) { + BitcoinDisplayUnit.MODERN -> sats.toString() + BitcoinDisplayUnit.CLASSIC -> sats.asBtc() + .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) + .orEmpty() + } +} + +internal fun formatBitcoinValue( + btcValue: String, + displayUnit: BitcoinDisplayUnit, + locale: Locale = Locale.getDefault(), +): String { + if (btcValue.isEmpty()) return "" + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> formatGroupedInteger( + value = btcValue.filter { it.isDigit() }, + groupingSeparator = SATS_GROUPING_SEPARATOR, + ) + + BitcoinDisplayUnit.CLASSIC -> formatGroupedDecimal( + value = sanitizeDecimalInput(btcValue, locale), + groupingSeparator = SATS_GROUPING_SEPARATOR, + decimalSeparator = DECIMAL_SEPARATOR, + ) + } +} + +internal fun formatFiatValue( + fiatValue: String, + locale: Locale = Locale.getDefault(), +): String { + if (fiatValue.isEmpty()) return "" + val normalizedFiatValue = sanitizeDecimalInput( + raw = normalizeCalculatorDecimalInput( + rawValue = fiatValue, + locale = locale, + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ), + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ) + return formatGroupedDecimal( + value = normalizedFiatValue, + groupingSeparator = FIAT_GROUPING_SEPARATOR, + decimalSeparator = DECIMAL_SEPARATOR, + ) +} + +internal fun formatFiatPlaceholder( + fiatValue: String, + locale: Locale = Locale.getDefault(), +): String { + if (fiatValue.isEmpty()) return "" + val normalizedFiatValue = sanitizeDecimalInput( + raw = normalizeCalculatorDecimalInput( + rawValue = fiatValue, + locale = locale, + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ), + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ) + if (!normalizedFiatValue.contains(PERIOD_SEPARATOR)) return "" + + val decimalLength = normalizedFiatValue.substringAfter(PERIOD_SEPARATOR).length + val remainingDecimals = CALCULATOR_FIAT_DECIMAL_PLACES - decimalLength + return if (remainingDecimals > 0) "0".repeat(remainingDecimals) else "" +} + +internal fun applyNumberPadInput( + rawValue: String, + key: String, + maxDecimalPlaces: Int?, + locale: Locale = Locale.getDefault(), +): String { + val normalizedRawValue = if (maxDecimalPlaces == null) { + rawValue + } else { + normalizeCalculatorDecimalInput( + rawValue = rawValue, + locale = locale, + maxDecimalPlaces = maxDecimalPlaces, + ) + } + val nextValue = when { + key == KEY_DELETE -> normalizedRawValue.dropLast(1) + key == KEY_DECIMAL -> appendDecimalSeparator(normalizedRawValue, maxDecimalPlaces) + key == KEY_000 -> appendCalculatorDigits(normalizedRawValue, KEY_000) + key.length == 1 && key.first().isDigit() -> appendCalculatorDigits(normalizedRawValue, key) + else -> normalizedRawValue + } + + return if (maxDecimalPlaces == null) { + sanitizeIntegerInput(nextValue) + } else { + sanitizeDecimalInput( + raw = nextValue, + locale = locale, + maxDecimalPlaces = maxDecimalPlaces, + ) + } +} + +private fun normalizeCalculatorDecimalInput( + rawValue: String, + locale: Locale, + maxDecimalPlaces: Int?, +): String { + val value = rawValue.removeSpaces() + val hasComma = value.contains(COMMA_SEPARATOR) + val hasPeriod = value.contains(PERIOD_SEPARATOR) + if (hasComma && hasPeriod) return normalizeMixedDecimalSeparators(value) + if (!hasComma) return value + return if (shouldTreatCommaAsGrouping(value, locale, maxDecimalPlaces)) { + value.replace(oldValue = COMMA_SEPARATOR.toString(), newValue = "") + } else { + value.replace(oldChar = COMMA_SEPARATOR, newChar = PERIOD_SEPARATOR) + } +} + +private fun normalizeMixedDecimalSeparators(value: String): String { + val decimalSeparator = if (value.lastIndexOf(COMMA_SEPARATOR) > value.lastIndexOf(PERIOD_SEPARATOR)) { + COMMA_SEPARATOR + } else { + PERIOD_SEPARATOR + } + val groupingSeparator = if (decimalSeparator == COMMA_SEPARATOR) PERIOD_SEPARATOR else COMMA_SEPARATOR + return value + .replace(oldValue = groupingSeparator.toString(), newValue = "") + .replace(oldChar = decimalSeparator, newChar = PERIOD_SEPARATOR) +} + +private fun shouldTreatCommaAsGrouping( + value: String, + locale: Locale, + maxDecimalPlaces: Int?, +): Boolean { + if (value.count { it == COMMA_SEPARATOR } > 1) return true + + val decimalSeparator = DecimalFormatSymbols.getInstance(locale).decimalSeparator + if (decimalSeparator != COMMA_SEPARATOR) return true + + val fractionLength = value.substringAfter(COMMA_SEPARATOR).length + return maxDecimalPlaces != null && fractionLength > maxDecimalPlaces +} + +private fun formatGroupedInteger( + value: String, + groupingSeparator: Char, +): String { + if (value.isEmpty()) return "" + val normalized = value.trimStart('0').ifEmpty { "0" } + return normalized.reversed().chunked(GROUP_SIZE).joinToString(groupingSeparator.toString()).reversed() +} + +private fun formatGroupedDecimal( + value: String, + groupingSeparator: Char, + decimalSeparator: Char, +): String { + if (value.isEmpty()) return "" + if (value == ".") return decimalSeparator.toString() + + val decimalIndex = value.indexOf('.') + if (decimalIndex == -1) { + return formatGroupedIntegerPreservingZeros( + value = value, + groupingSeparator = groupingSeparator, + ) + } + + val integerPart = value.substring(0, decimalIndex) + val decimalPart = value.substring(decimalIndex + 1) + return formatGroupedIntegerPreservingZeros( + value = integerPart, + groupingSeparator = groupingSeparator, + ) + decimalSeparator + decimalPart +} + +private fun appendDecimalSeparator( + rawValue: String, + maxDecimalPlaces: Int?, +): String { + if (maxDecimalPlaces == null || rawValue.contains('.')) return rawValue + return if (rawValue.isEmpty()) "0." else "$rawValue." +} + +private fun appendCalculatorDigits(rawValue: String, digits: String): String { + if (rawValue != "0") return rawValue + digits + return digits.trimStart('0').ifEmpty { "0" } +} + +internal fun calculatorDecimalSeparator(): String = DECIMAL_SEPARATOR.toString() + +private fun formatGroupedIntegerPreservingZeros( + value: String, + groupingSeparator: Char, +): String { + if (value.isEmpty()) return "" + return value.reversed().chunked(GROUP_SIZE).joinToString(groupingSeparator.toString()).reversed() +} + +private const val GROUP_SIZE = 3 +private const val COMMA_SEPARATOR = ',' +private const val PERIOD_SEPARATOR = '.' diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt index 8352afda1f..3bde4d8c7c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt @@ -1,62 +1,50 @@ package to.bitkit.ui.screens.widgets.calculator -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R -import to.bitkit.ext.spaceToNewline +import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.Headline import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard -import to.bitkit.ui.shared.util.screen +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardEditor +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall +import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.keyboardAsState -import to.bitkit.viewmodels.CurrencyViewModel @Composable fun CalculatorPreviewScreen( viewModel: CalculatorViewModel = hiltViewModel(), - currencyViewModel: CurrencyViewModel?, onClose: () -> Unit, onBack: () -> Unit, ) { - val showWidgetTitles by viewModel.showWidgetTitles.collectAsStateWithLifecycle() val isCalculatorWidgetEnabled by viewModel.isCalculatorWidgetEnabled.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() CalculatorPreviewContent( onBack = onBack, isCalculatorWidgetEnabled = isCalculatorWidgetEnabled, - showWidgetTitles = showWidgetTitles, + uiState = uiState, + onBtcChange = viewModel::onBtcInputChanged, + onFiatChange = viewModel::onFiatInputChanged, onClickDelete = { viewModel.removeWidget() onClose() @@ -65,7 +53,6 @@ fun CalculatorPreviewScreen( viewModel.saveWidget() onClose() }, - currencyViewModel = currencyViewModel ) } @@ -74,136 +61,125 @@ fun CalculatorPreviewContent( onBack: () -> Unit, onClickDelete: () -> Unit, onClickSave: () -> Unit, - showWidgetTitles: Boolean, - currencyViewModel: CurrencyViewModel?, isCalculatorWidgetEnabled: Boolean, + uiState: CalculatorUiState = CalculatorUiState(), + onBtcChange: (String) -> Unit = {}, + onFiatChange: (String) -> Unit = {}, ) { - val isKeyboardVisible by keyboardAsState() - - Column( + ScreenColumn( + noBackground = true, modifier = Modifier - .screen() - .testTag("facts_preview_screen") + .background(Colors.Gray7) + .testTag("calculator_preview_screen") ) { AppTopBar( - titleText = stringResource(R.string.widgets__widget__nav_title), + titleText = stringResource(R.string.widgets__calculator__name), onBackClick = onBack, - actions = { DrawerNavIcon() }, ) Column( modifier = Modifier - .imePadding() .padding(horizontal = 16.dp) - .testTag("main_content") + .weight(1f) ) { - AnimatedVisibility( - visible = !isKeyboardVisible, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - VerticalSpacer(26.dp) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .testTag("header_row") - ) { - Headline( - text = AnnotatedString( - stringResource(R.string.widgets__calculator__name).spaceToNewline(), - ), - modifier = Modifier.testTag("widget_title"), - ) - Icon( - painter = painterResource(R.drawable.widget_math_operation), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier - .size(64.dp) - .testTag("widget_icon") - ) - } - - BodyM( - text = stringResource(R.string.widgets__facts__description), - color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("widget_description") - ) - - HorizontalDivider( - modifier = Modifier.testTag("divider") - ) - } - } - - if (!isKeyboardVisible) { - FillHeight() - } + VerticalSpacer(16.dp) - Text13Up( - stringResource(R.string.common__preview), + BodyM( + text = stringResource(R.string.widgets__calculator__description).replace( + "{fiatSymbol}", + uiState.currencySymbol, + ), color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("preview_label") + modifier = Modifier.testTag("widget_description") ) - currencyViewModel?.let { - CalculatorCard( - showWidgetTitle = showWidgetTitles, - currencyViewModel = it, - modifier = Modifier.fillMaxWidth() - ) - } + VerticalSpacer(16.dp) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .padding(vertical = 21.dp) - .fillMaxWidth() - .testTag("buttons_row") - ) { - if (isCalculatorWidgetEnabled) { - SecondaryButton( - text = stringResource(R.string.common__delete), - fullWidth = false, - onClick = onClickDelete, + HorizontalDivider( + modifier = Modifier.testTag("divider") + ) + + WidgetSizeCarousel( + smallContent = { + CalculatorCardSmall( + btcPrimaryDisplayUnit = uiState.displayUnit, + btcValue = uiState.btcValue, + fiatSymbol = uiState.currencySymbol, + fiatValue = uiState.fiatValue, + modifier = Modifier.testTag("calculator_card_small") + ) + }, + wideContent = { + CalculatorCardEditor( + btcPrimaryDisplayUnit = uiState.displayUnit, + btcValue = uiState.btcValue, + onBtcChange = onBtcChange, + fiatSymbol = uiState.currencySymbol, + fiatName = uiState.selectedCurrency, + fiatValue = uiState.fiatValue, + onFiatChange = onFiatChange, modifier = Modifier - .weight(1f) - .testTag("WidgetDelete") + .fillMaxWidth() + .testTag("calculator_card_wide") ) - } + }, + modifier = Modifier + .fillMaxWidth() + .testTag("calculator_preview_carousel") + ) + } - PrimaryButton( - text = stringResource(R.string.common__save), + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + top = 22.dp, + ) + .fillMaxWidth() + .testTag("buttons_row") + ) { + if (isCalculatorWidgetEnabled) { + SecondaryButton( + text = stringResource(R.string.common__delete), fullWidth = false, - onClick = onClickSave, + onClick = onClickDelete, modifier = Modifier .weight(1f) - .testTag("WidgetSave") + .testTag("WidgetDelete") ) } + + PrimaryButton( + text = stringResource(R.string.widgets__widget__save), + fullWidth = false, + onClick = onClickSave, + modifier = Modifier + .weight(1f) + .testTag("WidgetSave") + ) } } } -@Preview(showBackground = true) +@Preview(showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { CalculatorPreviewContent( onBack = {}, - showWidgetTitles = true, onClickDelete = {}, onClickSave = {}, isCalculatorWidgetEnabled = false, - currencyViewModel = null + uiState = CalculatorUiState( + btcValue = "10000", + fiatValue = "6.25", + displayUnit = BitcoinDisplayUnit.MODERN, + currencySymbol = "$", + selectedCurrency = "USD", + ), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index 129a93f746..dc3b187cfe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -1,23 +1,54 @@ package to.bitkit.ui.screens.widgets.calculator +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.appwidget.CalculatorAppWidgetUpdater +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CLASSIC_DECIMALS import to.bitkit.models.WidgetType +import to.bitkit.models.asBtc +import to.bitkit.models.formatCurrency +import to.bitkit.models.formatToModernDisplay import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WidgetsRepo +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormatSymbols +import java.util.Locale import javax.inject.Inject +internal const val CALCULATOR_FIAT_DECIMAL_PLACES = 2 + @HiltViewModel class CalculatorViewModel @Inject constructor( - private val widgetsRepo: WidgetsRepo + private val widgetsRepo: WidgetsRepo, + private val currencyRepo: CurrencyRepo, + private val appWidgetUpdater: CalculatorAppWidgetUpdater, ) : ViewModel() { + companion object { + private const val SUBSCRIPTION_TIMEOUT = 5000L + } + + private val _uiState = MutableStateFlow(CalculatorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var pendingValues: CalculatorValues? = null + private var lastCurrencyKey: CalculatorCurrencyKey? = null + val isCalculatorWidgetEnabled: StateFlow = widgetsRepo.widgetsDataFlow .map { widgetsData -> widgetsData.widgets.any { it.type == WidgetType.CALCULATOR } @@ -27,22 +58,11 @@ class CalculatorViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), initialValue = false ) - val calculatorValues: StateFlow = widgetsRepo.widgetsDataFlow - .map { widgetsData -> - widgetsData.calculatorValues - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), - initialValue = CalculatorValues() - ) - val showWidgetTitles: StateFlow = widgetsRepo.showWidgetTitles - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), - initialValue = true - ) + init { + observeCalculatorState() + } + fun removeWidget() { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.CALCULATOR) @@ -55,18 +75,257 @@ class CalculatorViewModel @Inject constructor( } } - fun updateCalculatorValues(fiatValue: String, btcValue: String) { + fun onBtcInputChanged(rawValue: String) { + val displayUnit = _uiState.value.displayUnit + val btcValue = if (displayUnit.isModern()) { + sanitizeIntegerInput(rawValue) + } else { + sanitizeDecimalInput(rawValue) + } + val fiatValue = if (btcValue.isEmpty()) { + "" + } else { + convertBtcToFiat(btcValue, displayUnit) + } + updateCalculatorValues( + CalculatorValues( + btcValue = btcValue, + fiatValue = fiatValue, + ) + ) + } + + fun onFiatInputChanged(rawValue: String) { + val displayUnit = _uiState.value.displayUnit + val fiatValue = sanitizeDecimalInput(rawValue, maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES) + val btcValue = if (fiatValue.isEmpty()) { + "" + } else { + val converted = convertFiatToBtc(fiatValue, displayUnit) + if (displayUnit.isModern()) { + converted.filter { it.isDigit() } + } else { + converted + } + } + updateCalculatorValues( + CalculatorValues( + btcValue = btcValue, + fiatValue = fiatValue, + ) + ) + } + + private fun observeCalculatorState() { viewModelScope.launch { - widgetsRepo.updateCalculatorValues( - calculatorValues = CalculatorValues( - fiatValue = fiatValue, - btcValue = btcValue + combine( + widgetsRepo.widgetsDataFlow + .map { it.calculatorValues } + .distinctUntilChanged(), + currencyRepo.currencyState, + ) { calculatorValues, currencyState -> + calculatorValues to currencyState + }.collect { (storedValues, currencyState) -> + val activeValues = resolveActiveValues(storedValues) + val nextValues = deriveValuesForCurrency( + activeValues = activeValues, + storedValues = storedValues, + currencyState = currencyState, ) + updateUiState(nextValues, currencyState) + } + } + } + + private fun resolveActiveValues(storedValues: CalculatorValues): CalculatorValues { + val pending = pendingValues ?: return storedValues + if (pending == storedValues) { + pendingValues = null + return storedValues + } + return pending + } + + private fun deriveValuesForCurrency( + activeValues: CalculatorValues, + storedValues: CalculatorValues, + currencyState: CurrencyState, + ): CalculatorValues { + val currencyKey = CalculatorCurrencyKey( + selectedCurrency = currencyState.selectedCurrency, + displayUnit = currencyState.displayUnit, + ) + val previousCurrencyKey = lastCurrencyKey + lastCurrencyKey = currencyKey + + val currencyChanged = previousCurrencyKey != null && previousCurrencyKey != currencyKey + val isInitialSync = previousCurrencyKey == null + val nextActiveValues = if (previousCurrencyKey == null) { + activeValues + } else { + activeValues.copy( + btcValue = convertCalculatorBtcValueUnit( + btcValue = activeValues.btcValue, + fromDisplayUnit = previousCurrencyKey.displayUnit, + toDisplayUnit = currencyState.displayUnit, + ), + ) + } + val shouldRefreshFiat = isInitialSync || currencyChanged || shouldHydrateFiatFromStoredBtc( + storedBtcValue = storedValues.btcValue, + storedFiatValue = storedValues.fiatValue, + currentFiatValue = nextActiveValues.fiatValue, + displayUnit = currencyState.displayUnit, + ) + + if (!shouldRefreshFiat) { + return nextActiveValues + } + if (nextActiveValues.btcValue.isEmpty() || + isZeroBtcValue(nextActiveValues.btcValue, currencyState.displayUnit) + ) { + return nextActiveValues + } + + val convertedFiat = convertBtcToFiat( + btcValue = nextActiveValues.btcValue, + displayUnit = currencyState.displayUnit, + ) + if (convertedFiat.isEmpty()) { + return nextActiveValues + } + + val updatedValues = nextActiveValues.copy(fiatValue = convertedFiat) + updateCalculatorValues(updatedValues) + return updatedValues + } + + private fun updateCalculatorValues(calculatorValues: CalculatorValues) { + pendingValues = calculatorValues + _uiState.update { + it.copy( + btcValue = calculatorValues.btcValue, + fiatValue = calculatorValues.fiatValue, ) } + viewModelScope.launch { + widgetsRepo.updateCalculatorValues(calculatorValues) + appWidgetUpdater.update() + } } - companion object { - private const val SUBSCRIPTION_TIMEOUT = 5000L + private fun updateUiState( + calculatorValues: CalculatorValues, + currencyState: CurrencyState, + ) { + _uiState.update { + it.copy( + btcValue = calculatorValues.btcValue, + fiatValue = calculatorValues.fiatValue, + displayUnit = currencyState.displayUnit, + currencySymbol = currencyState.currencySymbol, + selectedCurrency = currencyState.selectedCurrency, + ) + } + } + + private fun convertBtcToFiat( + btcValue: String, + displayUnit: BitcoinDisplayUnit, + ): String { + val satsLong = calculatorBtcValueToSats(btcValue, displayUnit) + return currencyRepo.convertSatsToFiat(sats = satsLong).getOrNull() + ?.value + ?.toCalculatorFiatRawValue() + .orEmpty() + } + + private fun convertFiatToBtc( + fiatValue: String, + displayUnit: BitcoinDisplayUnit, + ): String { + val fiatDecimal = fiatValue.toBigDecimalOrNull() ?: BigDecimal.ZERO + val satsValue = currencyRepo.convertFiatToSats(fiatDecimal).getOrNull()?.toLong() ?: 0L + + return when (displayUnit) { + BitcoinDisplayUnit.MODERN -> satsValue.formatToModernDisplay() + BitcoinDisplayUnit.CLASSIC -> { + satsValue.asBtc() + .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) + .orEmpty() + } + } + } +} + +@Immutable +data class CalculatorUiState( + val btcValue: String = CalculatorValues().btcValue, + val fiatValue: String = CalculatorValues().fiatValue, + val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, + val currencySymbol: String = "$", + val selectedCurrency: String = "USD", +) + +private data class CalculatorCurrencyKey( + val selectedCurrency: String, + val displayUnit: BitcoinDisplayUnit, +) + +internal fun shouldHydrateFiatFromStoredBtc( + storedBtcValue: String, + storedFiatValue: String, + currentFiatValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean { + if (storedBtcValue.isEmpty()) { + return false + } + if (isZeroBtcValue(storedBtcValue, displayUnit)) { + return false + } + if (storedFiatValue.isNotEmpty()) { + return false + } + return currentFiatValue.isEmpty() +} + +internal fun isZeroBtcValue( + btcValue: String, + displayUnit: BitcoinDisplayUnit, +): Boolean = when (displayUnit) { + BitcoinDisplayUnit.MODERN -> btcValue == "0" + BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 +} + +internal fun sanitizeIntegerInput(raw: String): String { + val digits = raw.filter { it.isDigit() } + if (digits.isEmpty()) return digits + return digits.trimStart('0').ifEmpty { "0" } +} + +private fun BigDecimal.toCalculatorFiatRawValue(): String = + setScale(CALCULATOR_FIAT_DECIMAL_PLACES, RoundingMode.HALF_UP).toPlainString() + +internal fun sanitizeDecimalInput( + raw: String, + locale: Locale = Locale.getDefault(), + maxDecimalPlaces: Int? = null, +): String { + val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator + val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw + val filtered = normalized.filter { it.isDigit() || it == '.' } + val dotIndex = filtered.indexOf('.') + val singleDot = if (dotIndex == -1) { + filtered + } else { + filtered.substring(0, dotIndex + 1) + + filtered.substring(dotIndex + 1).replace(".", "") } + if (maxDecimalPlaces == null) return singleDot + val cappedDot = singleDot.indexOf('.') + if (cappedDot == -1) return singleDot + val fraction = singleDot.substring(cappedDot + 1) + if (fraction.length <= maxDecimalPlaces) return singleDot + return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces) } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt index 65883ab86d..ad49fef689 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -1,176 +1,355 @@ package to.bitkit.ui.screens.widgets.calculator.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement 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.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable 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.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.CLASSIC_DECIMALS +import to.bitkit.models.MoneyType import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.NavBarSpacer +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.screens.widgets.calculator.CALCULATOR_FIAT_DECIMAL_PLACES import to.bitkit.ui.screens.widgets.calculator.CalculatorViewModel +import to.bitkit.ui.screens.widgets.calculator.applyNumberPadInput +import to.bitkit.ui.screens.widgets.calculator.calculatorDecimalSeparator +import to.bitkit.ui.screens.widgets.calculator.formatBitcoinValue +import to.bitkit.ui.screens.widgets.calculator.formatFiatPlaceholder +import to.bitkit.ui.screens.widgets.calculator.formatFiatValue +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.visualTransformation.BitcoinVisualTransformation -import to.bitkit.ui.utils.visualTransformation.CalculatorFormatter -import to.bitkit.ui.utils.visualTransformation.MonetaryVisualTransformation -import to.bitkit.viewmodels.CurrencyViewModel -import java.math.BigDecimal - -private const val FIAT_DECIMAL_PLACES = 2 +import kotlin.time.Duration.Companion.milliseconds @Composable fun CalculatorCard( modifier: Modifier = Modifier, - currencyViewModel: CurrencyViewModel, + dismissNumberPadKey: Int = 0, + onInputActiveChange: (Boolean) -> Unit = {}, calculatorViewModel: CalculatorViewModel = hiltViewModel(), - showWidgetTitle: Boolean, ) { - val currencyUiState by currencyViewModel.uiState.collectAsStateWithLifecycle() - val calculatorValues by calculatorViewModel.calculatorValues.collectAsStateWithLifecycle() - var btcValue: String by rememberSaveable { mutableStateOf(calculatorValues.btcValue) } - var fiatValue: String by rememberSaveable { mutableStateOf(calculatorValues.fiatValue) } - val displayedBtcValue = btcValue.ifEmpty { calculatorValues.btcValue } - val displayedFiatValue = fiatValue - - LaunchedEffect( - calculatorValues.btcValue, - calculatorValues.fiatValue, - currencyUiState.displayUnit, - currencyUiState.selectedCurrency, - ) { - if (!shouldHydrateFiatFromStoredBtc( - storedBtcValue = calculatorValues.btcValue, - storedFiatValue = calculatorValues.fiatValue, - currentFiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - ) - ) { - return@LaunchedEffect - } - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = calculatorValues.btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - if (convertedFiat.isEmpty()) { - return@LaunchedEffect - } - fiatValue = convertedFiat - calculatorViewModel.updateCalculatorValues( - fiatValue = convertedFiat, - btcValue = calculatorValues.btcValue, + val uiState by calculatorViewModel.uiState.collectAsStateWithLifecycle() + + CalculatorCardEditor( + modifier = modifier, + btcPrimaryDisplayUnit = uiState.displayUnit, + btcValue = uiState.btcValue, + onBtcChange = calculatorViewModel::onBtcInputChanged, + fiatSymbol = uiState.currencySymbol, + fiatName = uiState.selectedCurrency, + fiatValue = uiState.fiatValue, + onFiatChange = calculatorViewModel::onFiatInputChanged, + dismissNumberPadKey = dismissNumberPadKey, + onInputActiveChange = onInputActiveChange, + ) +} + +@Composable +fun CalculatorCardEditor( + modifier: Modifier = Modifier, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + btcValue: String, + onBtcChange: (String) -> Unit, + fiatSymbol: String, + fiatName: String, + fiatValue: String, + onFiatChange: (String) -> Unit, + dismissNumberPadKey: Int = 0, + onInputActiveChange: (Boolean) -> Unit = {}, +) { + val numpadState = rememberNumpadState() + + Column(modifier = modifier) { + Content( + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + btcValue = btcValue, + fiatSymbol = fiatSymbol, + fiatName = fiatName, + fiatValue = fiatValue, + activeInput = numpadState.activeInput, + onSelectInput = numpadState::selectInput, + modifier = Modifier.fillMaxWidth(), + ) + + NumpadHost( + state = numpadState, + dismissNumberPadKey = dismissNumberPadKey, + onInputActiveChange = onInputActiveChange, + btcValue = btcValue, + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + fiatValue = fiatValue, + onBtcChange = onBtcChange, + onFiatChange = onFiatChange, ) } +} - LaunchedEffect(currencyUiState.selectedCurrency, currencyUiState.displayUnit) { - val sourceBtc = btcValue.ifEmpty { calculatorValues.btcValue } - if (sourceBtc.isEmpty() || isZeroBtcValue(sourceBtc, currencyUiState.displayUnit)) { - return@LaunchedEffect - } - val convertedFiat = CalculatorFormatter.convertBtcToFiat( - btcValue = sourceBtc, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - if (convertedFiat.isEmpty()) { - return@LaunchedEffect +@Composable +private fun rememberNumpadState(): NumpadState { + val activeInput = rememberSaveable { mutableStateOf(null) } + val selectedInput = rememberSaveable { mutableStateOf(null) } + val visibilityState = remember { + MutableTransitionState(activeInput.value != null).apply { + targetState = activeInput.value != null } - fiatValue = convertedFiat - calculatorViewModel.updateCalculatorValues( - fiatValue = convertedFiat, - btcValue = sourceBtc, + } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + return remember(activeInput, selectedInput, visibilityState, bringIntoViewRequester) { + NumpadState( + activeInputState = activeInput, + selectedInputState = selectedInput, + visibilityState = visibilityState, + bringIntoViewRequester = bringIntoViewRequester, ) } +} - CalculatorCardContent( - modifier = modifier, - showWidgetTitle = showWidgetTitle, - btcPrimaryDisplayUnit = currencyUiState.displayUnit, - btcValue = displayedBtcValue, - onBtcChange = { rawValue -> - val sanitized = if (currencyUiState.displayUnit.isModern()) { - sanitizeIntegerInput(rawValue) - } else { - sanitizeDecimalInput(rawValue) - } - btcValue = sanitized - fiatValue = if (sanitized.isEmpty()) { - "" - } else { - CalculatorFormatter.convertBtcToFiat( - btcValue = btcValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, - ).orEmpty() - } - calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) - }, - fiatSymbol = currencyUiState.currencySymbol, - fiatName = currencyUiState.selectedCurrency, - fiatValue = displayedFiatValue, - onFiatChange = { rawValue -> - val sanitized = sanitizeDecimalInput(rawValue, maxDecimalPlaces = FIAT_DECIMAL_PLACES) - fiatValue = sanitized - btcValue = if (sanitized.isEmpty()) { - "" - } else { - val converted = CalculatorFormatter.convertFiatToBtc( - fiatValue = fiatValue, - displayUnit = currencyUiState.displayUnit, - currencyViewModel = currencyViewModel, +@Stable +private class NumpadState( + activeInputState: MutableState, + selectedInputState: MutableState, + val visibilityState: MutableTransitionState, + val bringIntoViewRequester: BringIntoViewRequester, +) { + var activeInput by activeInputState + private set + + var selectedInput by selectedInputState + private set + + var errorKey by mutableStateOf(null) + private set + + fun selectInput(input: MoneyType) { + selectedInput = input + activeInput = input + visibilityState.targetState = true + clearError() + } + + fun dismiss() { + activeInput = null + visibilityState.targetState = false + clearError() + } + + fun showError(key: String) { + errorKey = key + } + + fun clearError() { + errorKey = null + } +} + +@Composable +private fun ColumnScope.NumpadHost( + state: NumpadState, + dismissNumberPadKey: Int, + onInputActiveChange: (Boolean) -> Unit, + btcValue: String, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + fiatValue: String, + onBtcChange: (String) -> Unit, + onFiatChange: (String) -> Unit, +) { + NumpadEffects( + state = state, + dismissNumberPadKey = dismissNumberPadKey, + onInputActiveChange = onInputActiveChange, + ) + + Numpad( + state = state, + btcValue = btcValue, + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + fiatValue = fiatValue, + onBtcChange = onBtcChange, + onFiatChange = onFiatChange, + ) +} + +@Composable +private fun NumpadEffects( + state: NumpadState, + dismissNumberPadKey: Int, + onInputActiveChange: (Boolean) -> Unit, +) { + val updatedOnInputActiveChange by rememberUpdatedState(onInputActiveChange) + val isInputTargetActive = state.visibilityState.targetState + + LaunchedEffect(dismissNumberPadKey) { state.dismiss() } + + LaunchedEffect(state.activeInput, state.selectedInput) { + if (state.activeInput == null || state.selectedInput == null) return@LaunchedEffect + delay(BRING_NUMBER_PAD_INTO_VIEW_DELAY) + state.bringIntoViewRequester.bringIntoView() + } + + LaunchedEffect(isInputTargetActive) { + updatedOnInputActiveChange(isInputTargetActive) + } + + LaunchedEffect(state.errorKey) { + if (state.errorKey == null) return@LaunchedEffect + delay(ERROR_DELAY) + state.clearError() + } + + DisposableEffect(Unit) { + onDispose { updatedOnInputActiveChange(false) } + } +} + +@Composable +private fun ColumnScope.Numpad( + state: NumpadState, + btcValue: String, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + fiatValue: String, + onBtcChange: (String) -> Unit, + onFiatChange: (String) -> Unit, +) { + val selectedInput = state.selectedInput + + AnimatedVisibility( + visibleState = state.visibilityState, + enter = EnterTransition.None, + exit = fadeOut() + shrinkVertically(), + ) { + selectedInput?.let { input -> + Column( + modifier = Modifier.bringIntoViewRequester(state.bringIntoViewRequester) + ) { + VerticalSpacer(8.dp) + NumberPad( + onPress = { key -> + val currentValue = currentInputValue( + input = input, + btcValue = btcValue, + fiatValue = fiatValue, + ) + val nextValue = nextInputValue( + input = input, + key = key, + btcValue = btcValue, + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + fiatValue = fiatValue, + ) + + if (nextValue == currentValue && key != KEY_DELETE) { + state.showError(key) + return@NumberPad + } + state.clearError() + + when (input) { + MoneyType.BITCOIN -> onBtcChange(nextValue) + MoneyType.FIAT -> onFiatChange(nextValue) + } + }, + type = when (input) { + MoneyType.BITCOIN if btcPrimaryDisplayUnit.isModern() -> NumberPadType.INTEGER + else -> NumberPadType.DECIMAL + }, + decimalSeparator = calculatorDecimalSeparator(), + errorKey = state.errorKey, + includeNavigationBarsPadding = true, + modifier = Modifier + .testTag("CalculatorNumberPad") ) - if (currencyUiState.displayUnit.isModern()) { - converted.filter { it.isDigit() } - } else { - converted - } + NavBarSpacer(modifier = Modifier.background(MaterialTheme.colorScheme.background)) } - calculatorViewModel.updateCalculatorValues(fiatValue = fiatValue, btcValue = btcValue) + } + } +} + +private fun currentInputValue( + input: MoneyType, + btcValue: String, + fiatValue: String, +): String = when (input) { + MoneyType.BITCOIN -> btcValue + MoneyType.FIAT -> fiatValue +} + +private fun nextInputValue( + input: MoneyType, + key: String, + btcValue: String, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + fiatValue: String, +): String = when (input) { + MoneyType.BITCOIN -> applyNumberPadInput( + rawValue = btcValue, + key = key, + maxDecimalPlaces = CLASSIC_DECIMALS.takeUnless { + btcPrimaryDisplayUnit.isModern() }, ) + + MoneyType.FIAT -> applyNumberPadInput( + rawValue = fiatValue, + key = key, + maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES, + ) } @Composable -fun CalculatorCardContent( +private fun Content( modifier: Modifier = Modifier, - showWidgetTitle: Boolean, btcPrimaryDisplayUnit: BitcoinDisplayUnit, btcValue: String, - onBtcChange: (String) -> Unit, fiatSymbol: String, fiatName: String, fiatValue: String, - onFiatChange: (String) -> Unit, + activeInput: MoneyType? = null, + onSelectInput: (MoneyType) -> Unit = {}, ) { Box( modifier = modifier @@ -182,118 +361,151 @@ fun CalculatorCardContent( .fillMaxWidth() .padding(16.dp) ) { - if (showWidgetTitle) { - WidgetTitleRow() - Spacer(modifier = Modifier.height(16.dp)) - } - - // Bitcoin input with visual transformation CalculatorInput( - value = btcValue, - onValueChange = onBtcChange, + value = formatBitcoinValue(btcValue, btcPrimaryDisplayUnit), currencySymbol = BITCOIN_SYMBOL, currencyName = stringResource(R.string.settings__general__unit_bitcoin), - keyboardType = if (btcPrimaryDisplayUnit.isModern()) KeyboardType.Number else KeyboardType.Decimal, - visualTransformation = BitcoinVisualTransformation(btcPrimaryDisplayUnit), - modifier = Modifier.fillMaxWidth() + isActive = activeInput == MoneyType.BITCOIN, + onClick = { onSelectInput(MoneyType.BITCOIN) }, + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorBtcInput") ) VerticalSpacer(16.dp) - // Fiat input with decimal transformation CalculatorInput( - value = fiatValue, - onValueChange = onFiatChange, + value = formatFiatValue(fiatValue), currencySymbol = fiatSymbol, currencyName = fiatName, - keyboardType = KeyboardType.Decimal, - visualTransformation = MonetaryVisualTransformation(decimalPlaces = FIAT_DECIMAL_PLACES), - modifier = Modifier.fillMaxWidth() + isActive = activeInput == MoneyType.FIAT, + onClick = { onSelectInput(MoneyType.FIAT) }, + placeholder = formatFiatPlaceholder(fiatValue), + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorFiatInput") ) } } } -internal fun shouldHydrateFiatFromStoredBtc( - storedBtcValue: String, - storedFiatValue: String, - currentFiatValue: String, - displayUnit: BitcoinDisplayUnit, -): Boolean { - if (storedBtcValue.isEmpty()) { - return false - } - if (isZeroBtcValue(storedBtcValue, displayUnit)) { - return false - } - if (storedFiatValue.isNotEmpty()) { - return false - } - return currentFiatValue.isEmpty() -} +private val BRING_NUMBER_PAD_INTO_VIEW_DELAY = 120.milliseconds +private val ERROR_DELAY = 500.milliseconds -internal fun isZeroBtcValue( +@Composable +fun CalculatorCardSmall( + btcPrimaryDisplayUnit: BitcoinDisplayUnit, btcValue: String, - displayUnit: BitcoinDisplayUnit, -): Boolean = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> btcValue == "0" - BitcoinDisplayUnit.CLASSIC -> btcValue.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) == 0 + fiatSymbol: String, + fiatValue: String, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + modifier = modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .clip(shape = MaterialTheme.shapes.medium) + .background(Colors.Gray6) + .padding(16.dp) + .testTag("calculator_card_small") + ) { + ReadOnlyRow( + currencySymbol = BITCOIN_SYMBOL, + value = formatBitcoinValue(btcValue, btcPrimaryDisplayUnit), + iconSize = 24.dp, + rowPadding = 12.dp, + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorSmallBtcRow") + ) + ReadOnlyRow( + currencySymbol = fiatSymbol, + value = formatFiatValue(fiatValue), + iconSize = 24.dp, + rowPadding = 12.dp, + modifier = Modifier + .fillMaxWidth() + .testTag("CalculatorSmallFiatRow") + ) + } } @Composable -private fun WidgetTitleRow() { +private fun ReadOnlyRow( + currencySymbol: String, + value: String, + iconSize: Dp, + rowPadding: Dp, + modifier: Modifier = Modifier, +) { + val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() + Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.testTag("widget_title_row") + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .clip(MaterialTheme.shapes.small) + .background(Colors.Black) + .padding(rowPadding) ) { - Icon( - painter = painterResource(R.drawable.widget_math_operation), - contentDescription = null, + Box( + contentAlignment = Alignment.Center, modifier = Modifier - .size(32.dp) - .testTag("widget_title_icon"), - tint = Color.Unspecified - ) - Spacer(modifier = Modifier.width(16.dp)) + .background(color = Colors.Gray6, shape = CircleShape) + .size(iconSize) + ) { + BodyMSB( + text = displayCurrencySymbol, + color = Colors.Brand, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } BodyMSB( - text = stringResource(R.string.widgets__calculator__name), - modifier = Modifier.testTag("widget_title_text") + text = value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) } } -@Preview(showBackground = true) +@Preview @Composable private fun Preview() { AppThemeSurface { Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp) ) { - CalculatorCardContent( - modifier = Modifier.fillMaxWidth(), - showWidgetTitle = true, - btcValue = "1800000000", // Will display as "1 800 000 000" in MODERN mode + CalculatorCardEditor( + btcValue = "1800000000", onBtcChange = {}, fiatSymbol = "$", fiatValue = "4.55", fiatName = "USD", onFiatChange = {}, - btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN + btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, + modifier = Modifier.fillMaxWidth() ) - CalculatorCardContent( - modifier = Modifier.fillMaxWidth(), - showWidgetTitle = false, - btcValue = "22200000", // Will display as "0.22200000" in CLASSIC mode + CalculatorCardEditor( + btcValue = "22200000", onBtcChange = {}, fiatSymbol = "$", fiatValue = "4.55", fiatName = "USD", onFiatChange = {}, - btcPrimaryDisplayUnit = BitcoinDisplayUnit.CLASSIC + btcPrimaryDisplayUnit = BitcoinDisplayUnit.CLASSIC, + modifier = Modifier.fillMaxWidth() + ) + + CalculatorCardSmall( + btcValue = "10000", + fiatValue = "6.25", + fiatSymbol = "$", + btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt index d7f646d7f8..fc47a5ffd0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt @@ -4,94 +4,172 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardOptions +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.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB -import to.bitkit.ui.components.TextInput -import to.bitkit.ui.theme.AppTextFieldDefaults +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import java.text.DecimalFormatSymbols -import java.util.Locale +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +val inputShape @Composable get() = MaterialTheme.shapes.small +private val BRING_INPUT_INTO_VIEW_DELAY = 120.milliseconds +private val CURSOR_WIDTH = 2.dp +private val CURSOR_HEIGHT = 22.dp +private val CURSOR_BLINK_INTERVAL = 500.milliseconds @Composable fun CalculatorInput( value: String, - onValueChange: (String) -> Unit, currencySymbol: String, currencyName: String, + isActive: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, - keyboardType: KeyboardType = KeyboardType.Number, - visualTransformation: VisualTransformation = VisualTransformation.None, + placeholder: String = "", ) { val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() + val bringIntoViewRequester = remember { BringIntoViewRequester() } - TextInput( - value = value, - singleLine = true, - onValueChange = onValueChange, - leadingIcon = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background(color = Colors.Gray6, shape = CircleShape) - .size(32.dp) - ) { - BodyMSB(displayCurrencySymbol, color = Colors.Brand) - } - }, - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType, - ), - suffix = { CaptionB(currencyName.uppercase(), color = Colors.Gray1) }, - colors = AppTextFieldDefaults.noIndicatorColors.copy( - focusedContainerColor = Colors.Black, - unfocusedContainerColor = Colors.Black - ), - visualTransformation = visualTransformation, + LaunchedEffect(isActive) { + if (!isActive) return@LaunchedEffect + delay(BRING_INPUT_INTO_VIEW_DELAY) + bringIntoViewRequester.bringIntoView() + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier - ) + .bringIntoViewRequester(bringIntoViewRequester) + .clip(inputShape) + .background(Colors.Black) + .clickableAlpha(onClick = onClick) + .padding(16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(color = Colors.Gray6, shape = CircleShape) + .size(32.dp) + ) { + BodyMSB(displayCurrencySymbol, color = Colors.Brand) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + InputValue( + value = value, + placeholder = placeholder, + isActive = isActive, + modifier = Modifier.weight(weight = 1f, fill = false) + ) + } + CaptionB( + text = currencyName.uppercase(), + color = Colors.Gray1, + maxLines = 1, + ) + } } -internal fun sanitizeIntegerInput(raw: String): String { - val digits = raw.filter { it.isDigit() } - if (digits.isEmpty()) return digits - return digits.trimStart('0').ifEmpty { "0" } +@Composable +private fun InputValue( + value: String, + placeholder: String, + isActive: Boolean, + modifier: Modifier = Modifier, +) { + var textLayout by remember { mutableStateOf(null) } + val text = remember(value, placeholder) { + buildAnnotatedString { + append(value) + withStyle(SpanStyle(color = Colors.White50)) { + append(placeholder) + } + } + } + + Box(modifier = modifier) { + Text( + text = text, + style = AppTextStyles.BodyMSB.merge( + color = MaterialTheme.colorScheme.primary, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { textLayout = it }, + ) + if (isActive) { + Cursor( + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + IntOffset( + x = textLayout.cursorOffset(value).roundToInt(), + y = 0, + ) + } + ) + } + } } -internal fun sanitizeDecimalInput( - raw: String, - locale: Locale = Locale.getDefault(), - maxDecimalPlaces: Int? = null, -): String { - val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator - val normalized = if (localDecimal == ',') raw.replace(',', '.') else raw - val filtered = normalized.filter { it.isDigit() || it == '.' } - val dotIndex = filtered.indexOf('.') - val singleDot = if (dotIndex == -1) { - filtered - } else { - filtered.substring(0, dotIndex + 1) + - filtered.substring(dotIndex + 1).replace(".", "") +private fun TextLayoutResult?.cursorOffset(value: String): Float { + if (this == null || value.isEmpty()) return 0f + return getCursorRect(value.length).left +} + +@Composable +private fun Cursor(modifier: Modifier = Modifier) { + var isVisible by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + while (true) { + delay(CURSOR_BLINK_INTERVAL) + isVisible = !isVisible + } } - if (maxDecimalPlaces == null) return singleDot - val cappedDot = singleDot.indexOf('.') - if (cappedDot == -1) return singleDot - val fraction = singleDot.substring(cappedDot + 1) - if (fraction.length <= maxDecimalPlaces) return singleDot - return singleDot.substring(0, cappedDot + 1) + fraction.take(maxDecimalPlaces) + + Box( + modifier = modifier + .width(CURSOR_WIDTH) + .height(CURSOR_HEIGHT) + .background(if (isVisible) Colors.Brand else Color.Transparent) + ) } internal fun String.toCalculatorDisplaySymbol(): String { @@ -103,28 +181,29 @@ internal fun String.toCalculatorDisplaySymbol(): String { } } -@Preview(showBackground = true) +@Preview @Composable private fun Preview() { AppThemeSurface { Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = Modifier.padding(16.dp) ) { CalculatorInput( - value = "100000", - onValueChange = {}, + value = "123 456 789", currencySymbol = "₿", currencyName = "BITCOIN", + isActive = true, + onClick = {}, modifier = Modifier.fillMaxWidth() ) CalculatorInput( - value = "4.55", - onValueChange = {}, + value = "82,209.8", currencySymbol = "$", currencyName = "USD", + isActive = true, + onClick = {}, + placeholder = "0", modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt deleted file mode 100644 index ccf9c1474b..0000000000 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformation.kt +++ /dev/null @@ -1,140 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -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 -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.SATS_GROUPING_SEPARATOR -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale - -class BitcoinVisualTransformation( - private val displayUnit: BitcoinDisplayUnit -) : VisualTransformation { - - override fun filter(text: AnnotatedString): TransformedText { - val rawText = text.text - val sanitizedText = sanitizeInput(rawText) - - if (sanitizedText.isEmpty()) { - return TransformedText(AnnotatedString(""), OffsetMapping.Identity) - } - - val formattedText = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> formatModernDisplay(sanitizedText) - BitcoinDisplayUnit.CLASSIC -> formatClassicDisplay(sanitizedText) - } - - return TransformedText( - AnnotatedString(formattedText), - createOffsetMapping(rawText, formattedText), - ) - } - - private fun sanitizeInput(text: String): String = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> text.filter { it.isDigit() } - BitcoinDisplayUnit.CLASSIC -> sanitizeClassicInput(text) - } - - private fun sanitizeClassicInput(text: String, locale: Locale = Locale.getDefault()): String { - val localDecimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator - val normalized = if (localDecimal == ',') text.replace(',', '.') else text - val filtered = normalized.filter { it.isDigit() || it == '.' } - val dotIndex = filtered.indexOf('.') - if (dotIndex == -1) { - return filtered - } - return filtered.substring(0, dotIndex + 1) + - filtered.substring(dotIndex + 1).replace(".", "") - } - - private fun formatModernDisplay(text: String): String { - val digits = text.replace("$SATS_GROUPING_SEPARATOR", "") - if (digits.isEmpty()) { - return "" - } - val normalizedDigits = digits.trimStart('0').ifEmpty { "0" } - return normalizedDigits.reversed().chunked(3).joinToString(" ").reversed() - } - - private fun formatClassicDisplay(text: String): String { - val cleanText = text.replace(" ", "").replace(",", "") - if (cleanText.isEmpty() || cleanText == ".") { - return cleanText - } - - val endsWithDecimal = cleanText.endsWith(".") - val textToFormat = if (endsWithDecimal) cleanText.dropLast(1) else cleanText - if (textToFormat.isEmpty()) { - return cleanText - } - - val doubleValue = textToFormat.toDoubleOrNull() ?: return cleanText - - val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = ' ' - decimalSeparator = '.' - } - val formatter = DecimalFormat("#,##0.########", formatSymbols) - val formatted = formatter.format(doubleValue) - return if (endsWithDecimal) "$formatted." else formatted - } - - private fun createOffsetMapping(rawOriginal: String, transformed: String): OffsetMapping { - val rawToSanitizedCount = IntArray(rawOriginal.length + 1) - var dotSeen = false - var sanitizedSoFar = 0 - for (i in rawOriginal.indices) { - val char = rawOriginal[i] - val isKept = when { - displayUnit == BitcoinDisplayUnit.MODERN -> char.isDigit() - char.isDigit() -> true - char == '.' && !dotSeen -> { - dotSeen = true - true - } - else -> false - } - if (isKept) sanitizedSoFar++ - rawToSanitizedCount[i + 1] = sanitizedSoFar - } - val totalSanitized = sanitizedSoFar - val transformedNonSpaceCount = transformed.count { it != ' ' } - // MODERN mode strips leading zeros via formatModernDisplay; account for that gap so - // cursor positions over stripped raw digits collapse to the start of the displayed text. - val leadingStripped = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> (totalSanitized - transformedNonSpaceCount).coerceAtLeast(0) - BitcoinDisplayUnit.CLASSIC -> 0 - } - - return object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - val clamped = offset.coerceIn(0, rawOriginal.length) - val validCount = (rawToSanitizedCount[clamped] - leadingStripped).coerceAtLeast(0) - if (validCount >= transformedNonSpaceCount) return transformed.length - var transformedOffset = 0 - var counted = 0 - while (transformedOffset < transformed.length && counted < validCount) { - if (transformed[transformedOffset] != ' ') counted++ - transformedOffset++ - } - while (transformedOffset < transformed.length && transformed[transformedOffset] == ' ') { - transformedOffset++ - } - return transformedOffset - } - - override fun transformedToOriginal(offset: Int): Int { - val clamped = offset.coerceIn(0, transformed.length) - if (clamped >= transformed.length) return rawOriginal.length - val validCount = transformed.take(clamped).count { it != ' ' } + leadingStripped - for (i in 0..rawOriginal.length) { - if (rawToSanitizedCount[i] >= validCount) return i - } - return rawOriginal.length - } - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt deleted file mode 100644 index a20270fe3d..0000000000 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/CalculatorFormatter.kt +++ /dev/null @@ -1,56 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -import to.bitkit.ext.removeSpaces -import to.bitkit.ext.toLongOrDefault -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.CLASSIC_DECIMALS -import to.bitkit.models.SATS_IN_BTC -import to.bitkit.models.asBtc -import to.bitkit.models.formatCurrency -import to.bitkit.models.formatToModernDisplay -import to.bitkit.viewmodels.CurrencyViewModel -import java.math.BigDecimal -import java.math.RoundingMode - -object CalculatorFormatter { - - fun convertBtcToFiat( - btcValue: String, - displayUnit: BitcoinDisplayUnit, - currencyViewModel: CurrencyViewModel, - ): String? { - val satsOrBtc = btcValue.removeSpaces() - val satsLong = when (displayUnit) { - BitcoinDisplayUnit.MODERN -> satsOrBtc.toLongOrDefault() - BitcoinDisplayUnit.CLASSIC -> { - val btcDecimal = BigDecimal.valueOf(satsOrBtc.toDoubleOrNull() ?: 0.0) - val satsDecimal = btcDecimal.multiply(BigDecimal(SATS_IN_BTC)) - val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) - roundedNumber.toLong() - } - } - - val fiat = currencyViewModel.convert(sats = satsLong) - return fiat?.formatted - } - - fun convertFiatToBtc( - fiatValue: String, - displayUnit: BitcoinDisplayUnit, - currencyViewModel: CurrencyViewModel, - ): String { - val satsValue = currencyViewModel.convertFiatToSats(fiatValue.toDoubleOrNull() ?: 0.0) - - return when (displayUnit) { - BitcoinDisplayUnit.MODERN -> { - satsValue.formatToModernDisplay() - } - - BitcoinDisplayUnit.CLASSIC -> { - satsValue.asBtc() - .formatCurrency(decimalPlaces = CLASSIC_DECIMALS) - .orEmpty() - } - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt b/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt deleted file mode 100644 index 25ae408ff6..0000000000 --- a/app/src/main/java/to/bitkit/ui/utils/visualTransformation/MonetaryVisualTransformation.kt +++ /dev/null @@ -1,171 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -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 -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Locale -import kotlin.text.iterator - -class MonetaryVisualTransformation( - private val decimalPlaces: Int = 2, -) : VisualTransformation { - - companion object { - private const val GROUPING_SEPARATOR = ' ' - } - - override fun filter(text: AnnotatedString): TransformedText { - val originalText = text.text - - if (originalText.isEmpty()) { - return TransformedText(text, OffsetMapping.Identity) - } - - // Limit decimal places before formatting - val limitedText = limitDecimalPlaces(originalText) - val formattedText = formatMonetaryValue(limitedText) - val offsetMapping = createOffsetMapping(limitedText, formattedText) - - return TransformedText( - AnnotatedString(formattedText), - offsetMapping - ) - } - - private fun limitDecimalPlaces(text: String): String { - val cleanText = text.replace(",", "").replace("$GROUPING_SEPARATOR", "") - - val decimalIndex = cleanText.indexOf('.') - if (decimalIndex == -1) { - return cleanText - } - - val integerPart = cleanText.substring(0, decimalIndex) - val decimalPart = cleanText.substring(decimalIndex + 1) - - // Limit decimal part to specified places - val limitedDecimalPart = decimalPart.take(decimalPlaces) - - return if (limitedDecimalPart.isEmpty() && cleanText.endsWith(".")) { - "$integerPart." - } else if (limitedDecimalPart.isEmpty()) { - integerPart - } else { - "$integerPart.$limitedDecimalPart" - } - } - - private fun formatMonetaryValue(text: String): String { - // Handle cases where user is typing a decimal point - if (text.isEmpty() || text == ".") { - return text - } - - // If text ends with a decimal point, preserve it - val endsWithDecimal = text.endsWith(".") - val textToFormat = if (endsWithDecimal) text.dropLast(1) else text - - // If the text to format is empty after removing the decimal, return original - if (textToFormat.isEmpty()) { - return text - } - - val doubleValue = textToFormat.toDoubleOrNull() ?: return text - - val formatSymbols = DecimalFormatSymbols(Locale.getDefault()).apply { - groupingSeparator = GROUPING_SEPARATOR - decimalSeparator = '.' - } - - val formatter = if (endsWithDecimal) { - DecimalFormat("#,##0", formatSymbols) - } else { - val decimalPlacesPattern = "#".repeat(decimalPlaces) - DecimalFormat("#,##0.$decimalPlacesPattern", formatSymbols).apply { - minimumFractionDigits = 0 - maximumFractionDigits = decimalPlaces - } - } - - val formatted = formatter.format(doubleValue) - return if (endsWithDecimal) "$formatted." else formatted - } - - @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") - private fun createOffsetMapping(original: String, transformed: String): OffsetMapping { - return object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - if (offset <= 0) return 0 - if (offset >= original.length) return transformed.length - - val originalSubstring = original.take(offset) - var transformedOffset = 0 - var originalIndex = 0 - - for (char in transformed) { - if (originalIndex >= originalSubstring.length) break - - if (char == GROUPING_SEPARATOR) { - transformedOffset++ - } else if (originalIndex < originalSubstring.length && - originalSubstring[originalIndex] == char - ) { - // Characters match, advance both - originalIndex++ - transformedOffset++ - } else { - // Look for next matching character in original - var found = false - for (i in originalIndex until originalSubstring.length) { - if (originalSubstring[i] == char) { - originalIndex = i + 1 - transformedOffset++ - found = true - break - } - } - if (!found) break - } - } - - return transformedOffset.coerceAtMost(transformed.length) - } - - override fun transformedToOriginal(offset: Int): Int { - if (offset <= 0) return 0 - if (offset >= transformed.length) return original.length - - val transformedSubstring = transformed.take(offset) - var originalOffset = 0 - var transformedIndex = 0 - - for (char in original) { - if (transformedIndex >= transformedSubstring.length) break - - if (char == transformedSubstring[transformedIndex]) { - // Characters match, advance both - transformedIndex++ - originalOffset++ - } else if (transformedIndex < transformedSubstring.length - 1 && - transformedSubstring[transformedIndex] == GROUPING_SEPARATOR - ) { - transformedIndex++ - if (transformedIndex < transformedSubstring.length && - char == transformedSubstring[transformedIndex] - ) { - transformedIndex++ - originalOffset++ - } - } else { - originalOffset++ - } - } - - return originalOffset.coerceAtMost(original.length) - } - } - } -} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2e95d70d68..0a95e483e5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2683,6 +2683,8 @@ class AppViewModel @Inject constructor( } } + fun handleLaunchIntent(intent: Intent) = handleDeeplinkIntent(intent) + fun clearPendingPubkyImport() { viewModelScope.launch { pubkyRepo.clearPendingImport() diff --git a/app/src/main/res/drawable/appwidget_calculator_icon_background.xml b/app/src/main/res/drawable/appwidget_calculator_icon_background.xml new file mode 100644 index 0000000000..ca78f22f0b --- /dev/null +++ b/app/src/main/res/drawable/appwidget_calculator_icon_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/appwidget_calculator_row_background.xml b/app/src/main/res/drawable/appwidget_calculator_row_background.xml new file mode 100644 index 0000000000..a71d6f90c3 --- /dev/null +++ b/app/src/main/res/drawable/appwidget_calculator_row_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/appwidget_preview_calculator.xml b/app/src/main/res/layout/appwidget_preview_calculator.xml new file mode 100644 index 0000000000..cccdb69366 --- /dev/null +++ b/app/src/main/res/layout/appwidget_preview_calculator.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 790513476c..46e9dc0c4a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + Convert bitcoin amounts to fiat or vice versa. Loading… Currency Day diff --git a/app/src/main/res/xml/appwidget_info_calculator.xml b/app/src/main/res/xml/appwidget_info_calculator.xml new file mode 100644 index 0000000000..e90b23b84c --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_calculator.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt new file mode 100644 index 0000000000..56f333576b --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt @@ -0,0 +1,251 @@ +package to.bitkit.ui.screens.widgets.calculator + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.appwidget.CalculatorAppWidgetUpdater +import to.bitkit.data.WidgetsData +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.WidgetsRepo +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CalculatorViewModelTest : BaseUnitTest() { + + private val widgetsRepo: WidgetsRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val appWidgetUpdater: CalculatorAppWidgetUpdater = mock() + private val widgetsData = MutableStateFlow(WidgetsData()) + private val currencyState = MutableStateFlow(CurrencyState()) + private var lastConvertedSats = 0L + private var fiatConversionValue: String? = null + private var fiatConversionFormatted: String? = null + + private lateinit var sut: CalculatorViewModel + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + widgetsData.value = WidgetsData() + currencyState.value = CurrencyState() + lastConvertedSats = 0L + fiatConversionValue = null + fiatConversionFormatted = null + + whenever(widgetsRepo.widgetsDataFlow).thenReturn(widgetsData) + whenever(currencyRepo.currencyState).thenReturn(currencyState) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + val sats = it.getArgument(0) + lastConvertedSats = sats + val fiatValue = fiatConversionValue ?: currentFiatValue() + ConvertedAmount( + value = BigDecimal(fiatValue), + formatted = fiatConversionFormatted ?: fiatValue, + symbol = currencyState.value.currencySymbol, + currency = currencyState.value.selectedCurrency, + flag = "", + sats = sats, + ) + } + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { 12_345uL } + whenever { appWidgetUpdater.update() }.thenReturn(Unit) + whenever { widgetsRepo.updateCalculatorValues(any()) }.thenAnswer { + val calculatorValues = it.getArgument(0) + widgetsData.value = widgetsData.value.copy(calculatorValues = calculatorValues) + Unit + } + } + + @Test + fun `init hydrates fiat value from stored btc`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals("6.25", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `init refreshes fiat value when stored fiat already exists`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "1.00", + ) + ) + sut = createSut() + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals("6.25", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `onBtcInputChanged sanitizes converts and persists values`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("0888,,,,,,,.00000000") + advanceUntilIdle() + + assertEquals("88800000000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "88800000000", + fiatValue = "6.25", + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `onBtcInputChanged clears both values when input is empty`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("") + advanceUntilIdle() + + assertEquals("", sut.uiState.value.btcValue) + assertEquals("", sut.uiState.value.fiatValue) + assertEquals(CalculatorValues(btcValue = "", fiatValue = ""), widgetsData.value.calculatorValues) + } + + @Test + fun `onBtcInputChanged converts classic btc input to sats`() = test { + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("0.00010000") + advanceUntilIdle() + + assertEquals("0.00010000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(10_000L, lastConvertedSats) + } + + @Test + fun `onBtcInputChanged stores fiat conversion without grouping`() = test { + fiatConversionValue = "39621.05" + fiatConversionFormatted = "39,621.05" + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("59500000") + advanceUntilIdle() + + assertEquals("39621.05", sut.uiState.value.fiatValue) + assertEquals("39621.05", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `onFiatInputChanged sanitizes converts and persists values`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("12.345") + advanceUntilIdle() + + assertEquals("12345", sut.uiState.value.btcValue) + assertEquals("12.34", sut.uiState.value.fiatValue) + assertEquals( + CalculatorValues( + btcValue = "12345", + fiatValue = "12.34", + ), + widgetsData.value.calculatorValues, + ) + } + + @Test + fun `onFiatInputChanged clears both values when input is empty`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("") + advanceUntilIdle() + + assertEquals("", sut.uiState.value.btcValue) + assertEquals("", sut.uiState.value.fiatValue) + assertEquals(CalculatorValues(btcValue = "", fiatValue = ""), widgetsData.value.calculatorValues) + } + + @Test + fun `currency change refreshes fiat from active btc value`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "6.25", + ) + ) + sut = createSut() + advanceUntilIdle() + + currencyState.value = CurrencyState( + selectedCurrency = "EUR", + currencySymbol = "EUR", + displayUnit = BitcoinDisplayUnit.MODERN, + ) + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("5.50", sut.uiState.value.fiatValue) + assertEquals("EUR", sut.uiState.value.selectedCurrency) + assertEquals("EUR", sut.uiState.value.currencySymbol) + assertEquals("5.50", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `display unit change preserves btc amount`() = test { + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "6.25", + ) + ) + sut = createSut() + advanceUntilIdle() + + currencyState.value = CurrencyState(displayUnit = BitcoinDisplayUnit.CLASSIC) + advanceUntilIdle() + + assertEquals("0.00010000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(10_000L, lastConvertedSats) + assertEquals("0.00010000", widgetsData.value.calculatorValues.btcValue) + } + + private fun createSut() = CalculatorViewModel( + widgetsRepo = widgetsRepo, + currencyRepo = currencyRepo, + appWidgetUpdater = appWidgetUpdater, + ) + + private fun currentFiatValue() = when (currencyState.value.selectedCurrency) { + "EUR" -> "5.50" + else -> "6.25" + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt index 7574713a6e..d94dc33bdb 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -3,6 +3,18 @@ package to.bitkit.ui.screens.widgets.calculator.components import org.junit.Before import org.junit.Test import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.ui.components.KEY_000 +import to.bitkit.ui.components.KEY_DECIMAL +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.screens.widgets.calculator.CALCULATOR_FIAT_DECIMAL_PLACES +import to.bitkit.ui.screens.widgets.calculator.applyNumberPadInput +import to.bitkit.ui.screens.widgets.calculator.calculatorDecimalSeparator +import to.bitkit.ui.screens.widgets.calculator.formatBitcoinValue +import to.bitkit.ui.screens.widgets.calculator.formatFiatPlaceholder +import to.bitkit.ui.screens.widgets.calculator.formatFiatValue +import to.bitkit.ui.screens.widgets.calculator.sanitizeDecimalInput +import to.bitkit.ui.screens.widgets.calculator.sanitizeIntegerInput +import to.bitkit.ui.screens.widgets.calculator.shouldHydrateFiatFromStoredBtc import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -129,4 +141,79 @@ class CalculatorCardStateTest { assertEquals(".5", sanitizeDecimalInput(".5", maxDecimalPlaces = 2)) assertEquals("12.", sanitizeDecimalInput("12.", maxDecimalPlaces = 2)) } + + @Test + fun `formatBitcoinValue groups modern sats without changing raw value`() { + assertEquals("1 345", formatBitcoinValue("1345", BitcoinDisplayUnit.MODERN)) + assertEquals("12 345", formatBitcoinValue("12345", BitcoinDisplayUnit.MODERN)) + assertEquals("1 234 567", formatBitcoinValue("1234567", BitcoinDisplayUnit.MODERN)) + } + + @Test + fun `formatBitcoinValue groups classic bitcoin integer part`() { + assertEquals("1 345.67", formatBitcoinValue("1345.67", BitcoinDisplayUnit.CLASSIC)) + assertEquals("0.00010000", formatBitcoinValue("0.00010000", BitcoinDisplayUnit.CLASSIC)) + } + + @Test + fun `formatFiatValue groups fiat integer part`() { + assertEquals("1,345.67", formatFiatValue("1345.67")) + assertEquals("39,621.05", formatFiatValue("39,621.05")) + assertEquals("388,056,887.45", formatFiatValue("388056887.45")) + assertEquals("8.29", formatFiatValue("8.29")) + } + + @Test + fun `formatFiatPlaceholder returns missing decimal zeros`() { + assertEquals("", formatFiatPlaceholder("")) + assertEquals("", formatFiatPlaceholder("1")) + assertEquals("00", formatFiatPlaceholder("1.")) + assertEquals("0", formatFiatPlaceholder("1.2")) + assertEquals("", formatFiatPlaceholder("1.23")) + assertEquals("0", formatFiatPlaceholder("1,2", Locale.GERMANY)) + } + + @Test + fun `formatted values use app money separators`() { + assertEquals(".", calculatorDecimalSeparator()) + assertEquals( + "1 345.67", + formatBitcoinValue("1345.67", BitcoinDisplayUnit.CLASSIC, Locale.GERMANY), + ) + assertEquals("1,345.67", formatFiatValue("1345,67", Locale.GERMANY)) + assertEquals("388,056,887.45", formatFiatValue("388056887,45", Locale.GERMANY)) + } + + @Test + fun `applyNumberPadInput builds integer values`() { + assertEquals("1", applyNumberPadInput("", "1", maxDecimalPlaces = null)) + assertEquals("1000", applyNumberPadInput("1", KEY_000, maxDecimalPlaces = null)) + assertEquals("100", applyNumberPadInput("1000", KEY_DELETE, maxDecimalPlaces = null)) + assertEquals("100", applyNumberPadInput("100", KEY_DECIMAL, maxDecimalPlaces = null)) + } + + @Test + fun `applyNumberPadInput builds decimal values`() { + assertEquals("0.", applyNumberPadInput("", KEY_DECIMAL, maxDecimalPlaces = 2)) + assertEquals("0.5", applyNumberPadInput("0.", "5", maxDecimalPlaces = 2)) + assertEquals("0.56", applyNumberPadInput("0.5", "6", maxDecimalPlaces = 2)) + assertEquals("0.56", applyNumberPadInput("0.56", "7", maxDecimalPlaces = 2)) + assertEquals("0.5", applyNumberPadInput("0.56", KEY_DELETE, maxDecimalPlaces = 2)) + } + + @Test + fun `applyNumberPadInput deletes from grouped fiat values`() { + assertEquals( + "3960.5", + applyNumberPadInput("3,960.50", KEY_DELETE, CALCULATOR_FIAT_DECIMAL_PLACES, Locale.GERMANY), + ) + assertEquals( + "396", + applyNumberPadInput("3,960", KEY_DELETE, CALCULATOR_FIAT_DECIMAL_PLACES, Locale.GERMANY), + ) + assertEquals( + "3.9", + applyNumberPadInput("3,96", KEY_DELETE, CALCULATOR_FIAT_DECIMAL_PLACES, Locale.GERMANY), + ) + } } diff --git a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt b/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt deleted file mode 100644 index e799cf1534..0000000000 --- a/app/src/test/java/to/bitkit/ui/utils/visualTransformation/BitcoinVisualTransformationTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package to.bitkit.ui.utils.visualTransformation - -import androidx.compose.ui.text.AnnotatedString -import org.junit.Before -import org.junit.Test -import to.bitkit.models.BitcoinDisplayUnit -import java.util.Locale -import kotlin.test.assertEquals - -class BitcoinVisualTransformationTest { - - @Before - fun setLocale() { - Locale.setDefault(Locale.US) - } - - @Test - fun `modern filter strips non-digits from pasted input`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) - .filter(AnnotatedString("1000087188..........,,,,,")) - - assertEquals("1 000 087 188", result.text.text) - } - - @Test - fun `classic filter keeps single decimal separator only`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.CLASSIC) - .filter(AnnotatedString("1,23.4.5")) - - assertEquals("123.45", result.text.text) - } - - @Test - fun `modern filter cursor mapping handles leading zeros`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) - .filter(AnnotatedString("01000")) - - assertEquals("1 000", result.text.text) - - val mapping = result.offsetMapping - assertEquals(0, mapping.originalToTransformed(0)) - // Raw offset 1 (after the stripped leading '0') should still land at the - // start of "1 000", not past the displayed '1'. - assertEquals(0, mapping.originalToTransformed(1)) - assertEquals(5, mapping.originalToTransformed(5)) - // Transformed offset 1 (after the '1') maps back to raw offset 2. - assertEquals(2, mapping.transformedToOriginal(1)) - } - - @Test - fun `modern filter cursor mapping handles multiple leading zeros`() { - val result = BitcoinVisualTransformation(BitcoinDisplayUnit.MODERN) - .filter(AnnotatedString("00100")) - - assertEquals("100", result.text.text) - - val mapping = result.offsetMapping - assertEquals(0, mapping.originalToTransformed(0)) - assertEquals(0, mapping.originalToTransformed(1)) - assertEquals(0, mapping.originalToTransformed(2)) - assertEquals(1, mapping.originalToTransformed(3)) - assertEquals(3, mapping.originalToTransformed(5)) - } -} diff --git a/changelog.d/next/937.added.md b/changelog.d/next/937.added.md new file mode 100644 index 0000000000..933ddb768a --- /dev/null +++ b/changelog.d/next/937.added.md @@ -0,0 +1 @@ +Added the refreshed Bitcoin Calculator widget with compact and wide Android home screen variants.