diff --git a/AGENTS.md b/AGENTS.md index b7fef0e829..ce97e31683 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,7 +194,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS use `remember` for expensive Compose computations - ALWAYS declare `modifier: Modifier = Modifier,` as the FIRST optional parameter in composable declarations - ALWAYS pass `modifier = ...` as the LAST argument in composable calls -- ALWAYS add trailing commas in multi-line declarations; NEVER add a trailing comma to `modifier = ...` at call sites +- 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a01b77f41..9ecbc8842b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Home screen widgets foundation with Glance, including price widget as the first implementation #895 +### Changed +- Redesign price widget with v61 wide and compact layouts, new preview and edit screens, and tap-to-edit behavior #914 + ## [2.2.0] - 2026-04-07 ### Fixed 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 3ff9247cbc..7629dbdf62 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt @@ -1,5 +1,7 @@ package to.bitkit.appwidget.config +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,7 +12,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -24,13 +25,14 @@ import to.bitkit.appwidget.model.AppWidgetType import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.TradingPair import to.bitkit.models.widget.PricePreferences -import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.widgets.price.label import to.bitkit.ui.theme.Colors @Composable @@ -44,7 +46,7 @@ fun AppWidgetConfigScreen( when (state.type) { AppWidgetType.PRICE -> PriceConfigContent( state = state, - onTogglePair = { viewModel.togglePricePair(it) }, + onSelectPair = { viewModel.selectPricePair(it) }, onSelectPeriod = { viewModel.selectPricePeriod(it) }, onReset = { viewModel.resetPreferences() }, onSave = { viewModel.saveAndFinish(onConfirm) }, @@ -56,16 +58,21 @@ fun AppWidgetConfigScreen( @Composable private fun PriceConfigContent( state: AppWidgetConfigUiState, - onTogglePair: (TradingPair) -> Unit, + onSelectPair: (TradingPair) -> Unit, onSelectPeriod: (GraphPeriod) -> Unit, onReset: () -> Unit, onSave: () -> Unit, onCancel: () -> Unit, ) { val prefs = state.pricePreferences - ScreenColumn { + val selectedPair = prefs.enabledPairs.firstOrNull() ?: TradingPair.BTC_USD + + ScreenColumn( + noBackground = true, + modifier = Modifier.background(Colors.Gray7) + ) { AppTopBar( - titleText = stringResource(R.string.widgets__widget__edit), + titleText = stringResource(R.string.widgets__price__name), onBackClick = onCancel, ) @@ -75,43 +82,33 @@ private fun PriceConfigContent( .weight(1f) .verticalScroll(rememberScrollState()) ) { - VerticalSpacer(26.dp) - - BodyM( - text = stringResource(R.string.widgets__widget__edit_description).replace( - "{name}", - stringResource(R.string.widgets__price__name), - ), - color = Colors.White64, - ) - - VerticalSpacer(32.dp) + VerticalSpacer(16.dp) - BodySSB( - text = stringResource(R.string.appwidget__price__trading_pairs), + Caption13Up( + text = stringResource(R.string.appwidget__price__currency), color = Colors.White64, + modifier = Modifier.padding(bottom = 16.dp) ) - VerticalSpacer(8.dp) for (pair in TradingPair.entries) { - ConfigToggleRow( + SelectableRow( label = pair.displayName, - isEnabled = pair in prefs.enabledPairs, - onClick = { onTogglePair(pair) }, + isSelected = pair == selectedPair, + onClick = { onSelectPair(pair) }, ) } VerticalSpacer(16.dp) - BodySSB( - text = stringResource(R.string.appwidget__price__period), + Caption13Up( + text = stringResource(R.string.appwidget__price__timeframe), color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp) ) - VerticalSpacer(8.dp) for (period in GraphPeriod.entries) { - ConfigToggleRow( - label = period.value, - isEnabled = period == prefs.period, + SelectableRow( + label = period.label(), + isSelected = period == prefs.period, onClick = { onSelectPeriod(period) }, ) } @@ -120,7 +117,7 @@ private fun PriceConfigContent( Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 21.dp, horizontal = 16.dp) + .padding(16.dp) .fillMaxWidth() ) { SecondaryButton( @@ -143,9 +140,9 @@ private fun PriceConfigContent( } @Composable -private fun ConfigToggleRow( +private fun SelectableRow( label: String, - isEnabled: Boolean, + isSelected: Boolean, onClick: () -> Unit, ) { Column { @@ -153,19 +150,20 @@ private fun ConfigToggleRow( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(vertical = 12.dp) .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 14.dp) ) { BodySSB( text = label, - color = Colors.White64, + color = if (isSelected) Colors.White else Colors.White64, modifier = Modifier.weight(1f) ) - IconButton(onClick = onClick) { + if (isSelected) { Icon( painter = painterResource(R.drawable.ic_checkmark), contentDescription = null, - tint = if (isEnabled) Colors.Brand else Colors.White50, + tint = Colors.Brand, modifier = Modifier.size(32.dp) ) } 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 75954e3a63..9d36446b6e 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -46,15 +47,9 @@ class AppWidgetConfigViewModel @Inject constructor( } } - fun togglePricePair(pair: TradingPair) { + fun selectPricePair(pair: TradingPair) { _uiState.update { - val current = it.pricePreferences.enabledPairs.toMutableList() - if (pair in current) { - if (current.size > 1) current.remove(pair) - } else { - current.add(pair) - } - it.copy(pricePreferences = it.pricePreferences.copy(enabledPairs = current.sortedBy { p -> p.position })) + it.copy(pricePreferences = it.pricePreferences.copy(enabledPairs = persistentListOf(pair))) } } diff --git a/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceLayoutDimens.kt b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceLayoutDimens.kt new file mode 100644 index 0000000000..d23ba8881f --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceLayoutDimens.kt @@ -0,0 +1,11 @@ +package to.bitkit.appwidget.ui.components + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +object GlanceLayoutDimens { + val WIDE_LAYOUT_MIN_WIDTH = 280.dp + + val COMPACT_WIDGET_SIZE = DpSize(163.dp, 192.dp) + val WIDE_WIDGET_SIZE = DpSize(343.dp, 152.dp) +} 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 117f1fc9e8..37aed3364e 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 @@ -4,14 +4,14 @@ import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider import androidx.glance.action.clickable import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius import androidx.glance.background import androidx.glance.layout.Column import androidx.glance.layout.fillMaxSize import androidx.glance.layout.padding -import to.bitkit.appwidget.ui.theme.GlanceColors +import to.bitkit.R @Composable fun GlanceWidgetScaffold( @@ -20,8 +20,7 @@ fun GlanceWidgetScaffold( ) { val modifier = GlanceModifier .fillMaxSize() - .cornerRadius(16.dp) - .background(GlanceColors.cardBackgroundProvider) + .background(ImageProvider(R.drawable.appwidget_background)) .padding(16.dp) .let { mod -> if (onClick != null) mod.clickable(actionStartActivity(onClick)) else mod diff --git a/app/src/main/java/to/bitkit/appwidget/ui/price/LineChartBitmap.kt b/app/src/main/java/to/bitkit/appwidget/ui/price/LineChartBitmap.kt index e1c20dad85..eba40c3608 100644 --- a/app/src/main/java/to/bitkit/appwidget/ui/price/LineChartBitmap.kt +++ b/app/src/main/java/to/bitkit/appwidget/ui/price/LineChartBitmap.kt @@ -2,10 +2,8 @@ package to.bitkit.appwidget.ui.price import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Path -import android.graphics.Shader import androidx.annotation.ColorInt import androidx.core.graphics.createBitmap @@ -36,7 +34,7 @@ fun renderLineChartBitmap( x to y } - val linePath = buildSmoothPath(points) + val linePath = buildSmoothPath(points, yMin = padding, yMax = padding + drawHeight) val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = lineColor @@ -47,28 +45,14 @@ fun renderLineChartBitmap( } canvas.drawPath(linePath, linePaint) - val fillPath = Path(linePath).apply { - lineTo(points.last().first, height.toFloat()) - lineTo(points.first().first, height.toFloat()) - close() - } - - val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - shader = LinearGradient( - 0f, padding, - 0f, height.toFloat(), - (lineColor and 0x00FFFFFF) or 0xCC000000.toInt(), - (lineColor and 0x00FFFFFF) or 0x4D000000, - Shader.TileMode.CLAMP, - ) - style = Paint.Style.FILL - } - canvas.drawPath(fillPath, fillPaint) - return bitmap } -private fun buildSmoothPath(points: List>): Path = Path().apply { +private fun buildSmoothPath( + points: List>, + yMin: Float, + yMax: Float, +): Path = Path().apply { moveTo(points[0].first, points[0].second) for (i in 0 until points.size - 1) { val p0 = points[(i - 1).coerceAtLeast(0)] @@ -77,9 +61,9 @@ private fun buildSmoothPath(points: List>): Path = Path().app val p3 = points[(i + 2).coerceAtMost(points.lastIndex)] val cp1x = p1.first + (p2.first - p0.first) * SMOOTHING - val cp1y = p1.second + (p2.second - p0.second) * SMOOTHING + val cp1y = (p1.second + (p2.second - p0.second) * SMOOTHING).coerceIn(yMin, yMax) val cp2x = p2.first - (p3.first - p1.first) * SMOOTHING - val cp2y = p2.second - (p3.second - p1.second) * SMOOTHING + val cp2y = (p2.second - (p3.second - p1.second) * SMOOTHING).coerceIn(yMin, yMax) cubicTo(cp1x, cp1y, cp2x, cp2y, p2.first, p2.second) } diff --git a/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceContent.kt b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceContent.kt index 8ab8077d1d..da85b3fb2e 100644 --- a/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceContent.kt +++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceContent.kt @@ -3,6 +3,7 @@ package to.bitkit.appwidget.ui.price import android.appwidget.AppWidgetManager import android.content.Intent import android.graphics.Bitmap +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.glance.GlanceModifier @@ -21,30 +22,31 @@ import androidx.glance.layout.WidthModifier import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.padding +import androidx.glance.text.Text import androidx.glance.unit.Dimension import to.bitkit.R import to.bitkit.appwidget.config.AppWidgetConfigActivity import to.bitkit.appwidget.model.AppWidgetEntry import to.bitkit.appwidget.model.AppWidgetType -import to.bitkit.appwidget.ui.components.BodySB import to.bitkit.appwidget.ui.components.CaptionB +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.theme.GlanceColors -import to.bitkit.data.dto.price.PriceDTO +import to.bitkit.appwidget.ui.components.VerticalSpacer +import to.bitkit.appwidget.ui.theme.GlanceTextStyles +import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.ui.theme.Colors @Suppress("RestrictedApi") @Composable fun PriceGlanceContent( - price: PriceDTO?, + widget: PriceWidgetData?, + priceAvailable: Boolean, entry: AppWidgetEntry, chartBitmap: Bitmap? = null, ) { val context = LocalContext.current - val prefs = entry.pricePreferences - val showChart = LocalSize.current.height >= 160.dp val configIntent = Intent(context, AppWidgetConfigActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, entry.appWidgetId) @@ -52,68 +54,111 @@ fun PriceGlanceContent( } GlanceWidgetScaffold(onClick = configIntent) { - if (price == null) { + if (!priceAvailable || widget == null) { CaptionB(text = context.getString(R.string.appwidget__loading)) return@GlanceWidgetScaffold } - val enabledPairs = price.widgets.filter { it.pair in prefs.enabledPairs } - val displayWidgets = enabledPairs.ifEmpty { price.widgets.take(1) } - - displayWidgets.forEach { widget -> - PriceRow(widget = widget) - } - - if (showChart && chartBitmap != null) { - val chartWidget = displayWidgets.first() - val chartColor = if (chartWidget.change.isPositive) Colors.Green else Colors.Red - Box( - contentAlignment = Alignment.BottomStart, - modifier = GlanceModifier - .fillMaxWidth() - .padding(top = 8.dp) - .then(HeightModifier(Dimension.Expand)) - ) { - Image( - provider = ImageProvider(chartBitmap), - contentDescription = null, - contentScale = ContentScale.FillBounds, - modifier = GlanceModifier - .fillMaxWidth() - .fillMaxHeight() - .cornerRadius(8.dp) - ) - CaptionB( - text = chartWidget.period.value, - color = ColorProvider(day = chartColor, night = chartColor), - modifier = GlanceModifier.padding(7.dp) - ) - } + if (LocalSize.current.width >= GlanceLayoutDimens.WIDE_LAYOUT_MIN_WIDTH) { + WideContent(widget = widget, chartBitmap = chartBitmap) + } else { + CompactContent(widget = widget, chartBitmap = chartBitmap) } } } @Suppress("RestrictedApi") @Composable -private fun PriceRow(widget: PriceWidgetData) { +private fun WideContent(widget: PriceWidgetData, chartBitmap: Bitmap?) { + val changeColor = if (widget.change.isPositive) Colors.Green else Colors.Red + val periodLabel = LocalContext.current.getString(widget.period.labelRes()) + Row( verticalAlignment = Alignment.CenterVertically, - modifier = GlanceModifier.fillMaxWidth().padding(vertical = 4.dp) + modifier = GlanceModifier.fillMaxWidth() ) { - BodySB( - text = widget.pair.displayName, - color = GlanceColors.textSecondary, + Text( + text = "${widget.pair.displayName} $periodLabel".uppercase(), + style = GlanceTextStyles.captionUp, modifier = GlanceModifier.then(WidthModifier(Dimension.Expand)) ) - BodySB( + HorizontalSpacer(16.dp) + Text( text = widget.change.formatted, - color = if (widget.change.isPositive) { - ColorProvider(day = Colors.Green, night = Colors.Green) - } else { - ColorProvider(day = Colors.Red, night = Colors.Red) - }, + style = GlanceTextStyles.headlineChange22.copy( + color = ColorProvider(day = changeColor, night = changeColor), + ), + ) + } + VerticalSpacer(4.dp) + Text( + text = "${widget.pair.symbol} ${widget.price}", + style = GlanceTextStyles.headline34, + modifier = GlanceModifier.fillMaxWidth() + ) + VerticalSpacer(8.dp) + ChartBox(chartBitmap = chartBitmap) +} + +@Suppress("RestrictedApi") +@Composable +private fun CompactContent(widget: PriceWidgetData, chartBitmap: Bitmap?) { + val changeColor = if (widget.change.isPositive) Colors.Green else Colors.Red + val periodLabel = LocalContext.current.getString(widget.period.labelRes()) + + Row(modifier = GlanceModifier.fillMaxWidth()) { + Text( + text = widget.pair.displayName.uppercase(), + style = GlanceTextStyles.captionUp, + modifier = GlanceModifier.then(WidthModifier(Dimension.Expand)) + ) + Text( + text = periodLabel.uppercase(), + style = GlanceTextStyles.captionUp, + ) + } + VerticalSpacer(8.dp) + Text( + text = "${widget.pair.symbol} ${widget.price}", + style = GlanceTextStyles.title22, + modifier = GlanceModifier.fillMaxWidth() + ) + VerticalSpacer(8.dp) + Text( + text = widget.change.formatted, + style = GlanceTextStyles.bodySSB.copy( + color = ColorProvider(day = changeColor, night = changeColor), + ), + ) + ChartBox(chartBitmap = chartBitmap) +} + +@Suppress("RestrictedApi") +@Composable +private fun ChartBox(chartBitmap: Bitmap?) { + if (chartBitmap == null) return + Box( + modifier = GlanceModifier + .fillMaxWidth() + .then(HeightModifier(Dimension.Expand)) + .padding(vertical = 16.dp) + ) { + Image( + provider = ImageProvider(chartBitmap), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = GlanceModifier + .fillMaxWidth() + .fillMaxHeight() + .cornerRadius(8.dp) ) - HorizontalSpacer(16.dp) - BodySB(text = "${widget.pair.symbol}${widget.price}") } } + +@StringRes +private fun GraphPeriod.labelRes(): Int = when (this) { + GraphPeriod.ONE_DAY -> R.string.appwidget__price__day + GraphPeriod.ONE_WEEK -> R.string.appwidget__price__week + GraphPeriod.ONE_MONTH -> R.string.appwidget__price__month + GraphPeriod.ONE_YEAR -> R.string.appwidget__price__year +} diff --git a/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceWidget.kt b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceWidget.kt index 6f2781dca6..781ed69512 100644 --- a/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceWidget.kt +++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceWidget.kt @@ -6,8 +6,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetManager @@ -18,7 +16,9 @@ import to.bitkit.appwidget.AppWidgetEntryPoint import to.bitkit.appwidget.model.AppWidgetData import to.bitkit.appwidget.model.AppWidgetEntry import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.ui.components.GlanceLayoutDimens import to.bitkit.data.dto.price.PriceDTO +import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.ui.theme.Colors class PriceGlanceWidget : GlanceAppWidget() { @@ -26,12 +26,10 @@ class PriceGlanceWidget : GlanceAppWidget() { companion object { private const val CHART_WIDTH = 600 private const val CHART_HEIGHT = 200 - val COMPACT = DpSize(180.dp, 80.dp) - val EXPANDED = DpSize(180.dp, 180.dp) } override val sizeMode = SizeMode.Responsive( - setOf(COMPACT, EXPANDED), + setOf(GlanceLayoutDimens.COMPACT_WIDGET_SIZE, GlanceLayoutDimens.WIDE_WIDGET_SIZE), ) override suspend fun provideGlance(context: Context, id: GlanceId) { @@ -45,33 +43,39 @@ class PriceGlanceWidget : GlanceAppWidget() { val entry = data.entries.find { it.appWidgetId == appWidgetId } ?: AppWidgetEntry(appWidgetId = appWidgetId, type = AppWidgetType.PRICE) val price = data.cachedPrices[entry.pricePreferences.period] - val chartBitmap = remember(price, entry.pricePreferences) { - buildChartBitmap(price, entry) + val widget = remember(price, entry.pricePreferences) { + resolveWidget(price, entry) + } + val chartBitmap = remember(widget) { + widget?.let { buildChartBitmap(it) } } PriceGlanceContent( - price = price, + widget = widget, + priceAvailable = price != null, entry = entry, chartBitmap = chartBitmap, ) } } - private fun buildChartBitmap(price: PriceDTO?, entry: AppWidgetEntry): Bitmap? { - val prefs = entry.pricePreferences - val enabledWidgets = price?.widgets?.filter { it.pair in prefs.enabledPairs } - val chartData = enabledWidgets?.firstOrNull() ?: price?.widgets?.firstOrNull() - ?: return null - if (chartData.pastValues.size < 2) return null + private fun resolveWidget(price: PriceDTO?, entry: AppWidgetEntry): PriceWidgetData? { + val widgets = price?.widgets ?: return null + val enabledPairs = entry.pricePreferences.enabledPairs + return widgets.firstOrNull { it.pair in enabledPairs } ?: widgets.firstOrNull() + } + + private fun buildChartBitmap(widget: PriceWidgetData): Bitmap? { + if (widget.pastValues.size < 2) return null - val lineColor = if (chartData.change.isPositive) { + val lineColor = if (widget.change.isPositive) { Colors.Green.toArgb() } else { Colors.Red.toArgb() } return renderLineChartBitmap( - values = chartData.pastValues, + values = widget.pastValues, width = CHART_WIDTH, height = CHART_HEIGHT, lineColor = lineColor, diff --git a/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceColors.kt b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceColors.kt index 427bb161d7..b8705ff13d 100644 --- a/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceColors.kt +++ b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceColors.kt @@ -4,7 +4,6 @@ import androidx.glance.color.ColorProvider import to.bitkit.ui.theme.Colors object GlanceColors { - val cardBackgroundProvider = ColorProvider(day = Colors.Gray5, night = Colors.Gray5) val textPrimary = ColorProvider(day = Colors.White, night = Colors.White) val textSecondary = ColorProvider(day = Colors.White64, night = Colors.White64) val textTertiary = ColorProvider(day = Colors.White50, night = Colors.White50) diff --git a/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceTextStyles.kt b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceTextStyles.kt index 512241bc0b..cc7b9f0264 100644 --- a/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceTextStyles.kt +++ b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceTextStyles.kt @@ -10,5 +10,9 @@ object GlanceTextStyles { val bodySSB = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = GlanceColors.textPrimary) val bodySB = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.Bold, color = GlanceColors.textPrimary) val captionB = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Medium, color = GlanceColors.textSecondary) + val captionUp = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Medium, color = GlanceColors.textSecondary) + val title22 = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold, color = GlanceColors.textPrimary) + val headline34 = TextStyle(fontSize = 34.sp, fontWeight = FontWeight.Bold, color = GlanceColors.textPrimary) + val headlineChange22 = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold) val footnoteM = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium, color = GlanceColors.textSecondary) } diff --git a/app/src/main/java/to/bitkit/data/dto/price/PriceDTO.kt b/app/src/main/java/to/bitkit/data/dto/price/PriceDTO.kt index 22243cf7bc..f878f7efe4 100644 --- a/app/src/main/java/to/bitkit/data/dto/price/PriceDTO.kt +++ b/app/src/main/java/to/bitkit/data/dto/price/PriceDTO.kt @@ -7,5 +7,4 @@ import kotlinx.serialization.Serializable @Serializable data class PriceDTO( @Stable val widgets: List, - val source: String ) diff --git a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt index 07c097facc..5da7a4938a 100644 --- a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt @@ -36,7 +36,6 @@ class PriceService @Inject constructor( override val widgetType = WidgetType.PRICE override val refreshInterval = 1.minutes - private val sourceLabel = "Bitfinex.com" override suspend fun fetchData(): Result = runCatching { val period = widgetsStore.data.first().pricePreferences.period ?: GraphPeriod.ONE_DAY @@ -50,7 +49,7 @@ class PriceService @Inject constructor( .getOrNull() } if (widgets.isEmpty()) throw PriceError.InvalidResponse("No price data available") - PriceDTO(widgets = widgets, source = sourceLabel) + PriceDTO(widgets = widgets) }.onFailure { Logger.warn(e = it, msg = "Failed to fetch price data", context = TAG) } @@ -62,7 +61,7 @@ class PriceService @Inject constructor( val widgets = TradingPair.entries.mapNotNull { pair -> runCatching { fetchPairData(pair = pair, period = period) }.getOrNull() } - PriceDTO(widgets = widgets, source = sourceLabel) + PriceDTO(widgets = widgets) } }.awaitAll().filter { it.widgets.isNotEmpty() } } 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 6b6dd21cbc..b15c91d270 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 @@ -784,7 +784,6 @@ private fun Widgets( WidgetType.PRICE -> { homeUiState.currentPrice?.run { PriceCard( - showWidgetTitle = homeUiState.showWidgetTitles, pricePreferences = homeUiState.pricePreferences, priceDTO = homeUiState.currentPrice, modifier = Modifier @@ -982,7 +981,6 @@ private val previewArticle = ArticleModel( ) private val previewPrice = PriceDTO( - source = "Bitfinex.com", widgets = listOf( PriceWidgetData( pair = TradingPair.BTC_USD, diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt index 3fb365b2ad..f248c2e900 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt @@ -2,33 +2,33 @@ package to.bitkit.ui.screens.widgets.price import androidx.compose.animation.core.EaseInOutCubic import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image 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.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.material3.MaterialTheme import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember 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.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import ir.ehsannarmani.compose_charts.LineChart import ir.ehsannarmani.compose_charts.models.DividerProperties import ir.ehsannarmani.compose_charts.models.DrawStyle @@ -44,123 +44,146 @@ import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair import to.bitkit.models.widget.PricePreferences -import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.components.BodySB -import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @Composable fun PriceCard( modifier: Modifier = Modifier, - showWidgetTitle: Boolean, pricePreferences: PricePreferences, - priceDTO: PriceDTO + priceDTO: PriceDTO, ) { + val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { + priceDTO.widgets.firstOrNull { it.pair in pricePreferences.enabledPairs } + ?: priceDTO.widgets.firstOrNull() + } ?: return + Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) .background(Colors.White10) ) { Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp) ) { - if (showWidgetTitle) { - Row( - verticalAlignment = Alignment.CenterVertically, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .testTag("price_card_pair_row_${widgetData.pair.displayName}") + ) { + Caption13Up( + text = "${widgetData.pair.displayName} ${widgetData.period.value}", + color = Colors.White64, modifier = Modifier - .padding(bottom = 8.dp) - .testTag("price_card_widget_title_row") - ) { - Icon( - painter = painterResource(R.drawable.widget_chart_line), - contentDescription = null, - modifier = Modifier - .size(32.dp) - .testTag("price_card_widget_title_icon"), - tint = Color.Unspecified - ) - Spacer(modifier = Modifier.width(16.dp)) - BodyMSB( - text = stringResource(R.string.widgets__price__name), - modifier = Modifier.testTag("price_card_widget_title_text") - ) - } + .weight(1f) + .testTag("PriceWidgetRow-${widgetData.pair.displayName}") + ) + HorizontalSpacer(16.dp) + Text( + text = widgetData.change.formatted, + color = if (widgetData.change.isPositive) Colors.Green else Colors.Red, + fontSize = 22.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag("price_card_pair_change_${widgetData.pair}") + ) } - val enabledPairs = remember(pricePreferences.enabledPairs, priceDTO.widgets) { - priceDTO.widgets.filter { widgetData -> widgetData.pair in pricePreferences.enabledPairs } - } + Text( + text = "${widgetData.pair.symbol} ${widgetData.price}", + color = Colors.White, + fontSize = 34.sp, + lineHeight = 34.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .testTag("price_card_pair_price_${widgetData.pair}") + ) - enabledPairs.map { widgetData -> + ChartComponent( + widgetData = widgetData, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .testTag("price_card_chart") + ) + } + } +} + +@Composable +fun PriceCardSmall( + modifier: Modifier = Modifier, + pricePreferences: PricePreferences, + priceDTO: PriceDTO, +) { + val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { + priceDTO.widgets.firstOrNull { it.pair in pricePreferences.enabledPairs } + ?: priceDTO.widgets.firstOrNull() + } ?: return + + Box( + modifier = modifier + .clip(shape = MaterialTheme.shapes.medium) + .background(Colors.White10) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Row( + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .testTag("price_card_pair_row_${widgetData.pair.displayName}"), - horizontalArrangement = Arrangement.SpaceBetween + .testTag("price_card_small_pair_row_${widgetData.pair.displayName}") ) { - BodySB( + Caption13Up( text = widgetData.pair.displayName, color = Colors.White64, - modifier = Modifier - .weight(1f) - .testTag("PriceWidgetRow-${widgetData.pair.displayName}") ) - - BodySB( - text = widgetData.change.formatted, - color = if (widgetData.change.isPositive) Colors.Green else Colors.Red, - modifier = Modifier.testTag("price_card_pair_change_${widgetData.pair}") - ) - - Spacer(modifier = Modifier.width(16.dp)) - - BodySB( - text = widgetData.price, - color = Colors.White, - modifier = Modifier.testTag("price_card_pair_price_${widgetData.pair}") + Caption13Up( + text = widgetData.period.value, + color = Colors.White64, ) } - } - - val chartData = remember(enabledPairs, pricePreferences.period) { - enabledPairs.firstOrNull() ?: priceDTO.widgets.firstOrNull() - } - - chartData?.let { firstPriceData -> - ChartComponent( - widgetData = firstPriceData, + Text( + text = "${widgetData.pair.symbol} ${widgetData.price}", + color = Colors.White, + fontSize = 22.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Bold, modifier = Modifier .fillMaxWidth() - .padding(top = 16.63.dp) - .testTag("price_card_chart") + .testTag("price_card_small_pair_price_${widgetData.pair}") + ) + Text( + text = widgetData.change.formatted, + color = if (widgetData.change.isPositive) Colors.Green else Colors.Red, + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.testTag("price_card_small_pair_change_${widgetData.pair}") ) } - if (pricePreferences.showSource) { - Spacer(modifier = Modifier.height(8.dp)) - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .testTag("PriceWidgetSource") - ) { - CaptionB( - text = stringResource(R.string.widgets__widget__source), - color = Colors.White64, - modifier = Modifier.testTag("source_label") - ) - CaptionB( - text = priceDTO.source, - color = Colors.White64, - modifier = Modifier.testTag("source_text") - ) - } - } + ChartComponent( + widgetData = widgetData, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .testTag("price_card_small_chart") + ) } } } @@ -168,7 +191,7 @@ fun PriceCard( @Composable fun ChartComponent( widgetData: PriceWidgetData, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val baseColor = if (widgetData.change.isPositive) Colors.Green else Colors.Red @@ -180,107 +203,85 @@ fun ChartComponent( } Box( - modifier = modifier - .height(96.dp) - .clip(ShapeDefaults.Small) + modifier = modifier.clip(ShapeDefaults.Small) ) { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(R.drawable.chart_preview), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier.fillMaxSize() + ) + return@Box + } + LineChart( - modifier = Modifier.fillMaxSize(), data = remember(widgetData.pastValues, baseColor) { listOf( Line( label = widgetData.pair.displayName, values = widgetData.pastValues, color = SolidColor(baseColor), - firstGradientFillColor = baseColor.copy(alpha = 0.8f), - secondGradientFillColor = baseColor.copy(alpha = 0.3f), strokeAnimationSpec = tween(1000, easing = EaseInOutCubic), gradientAnimationDelay = 1000, drawStyle = DrawStyle.Stroke(width = 1.dp), - curvedEdges = true - ) + curvedEdges = true, + ), ) }, labelProperties = LabelProperties( - enabled = false + enabled = false, ), labelHelperProperties = LabelHelperProperties( - enabled = false + enabled = false, ), gridProperties = GridProperties( - enabled = false + enabled = false, ), indicatorProperties = HorizontalIndicatorProperties( - enabled = false + enabled = false, ), dividerProperties = DividerProperties( - enabled = false + enabled = false, ), minValue = minValue, - maxValue = maxValue - ) - - CaptionB( - text = widgetData.period.value, - color = baseColor, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(7.dp) + maxValue = maxValue, + modifier = Modifier.fillMaxSize() ) } } +private val SAMPLE_PAST_VALUES = listOf(1.0, 2.0, 1.5, 3.0, 2.5, 4.0) + @Preview(showBackground = true) @Composable private fun FullBlockCardPreview() { AppThemeSurface { Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp) ) { PriceCard( - modifier = Modifier.fillMaxWidth(), - showWidgetTitle = true, pricePreferences = PricePreferences( - showSource = true + showSource = true, ), priceDTO = PriceDTO( - source = "Bitfinex.com", widgets = listOf( PriceWidgetData( pair = TradingPair.BTC_USD, change = Change( isPositive = true, - formatted = "$ 20,326" - ), - price = "$20,326", - pastValues = listOf( - 1.0, - 2.0, - 3.0, - 4.0, - ), - period = GraphPeriod.ONE_DAY, - ), - PriceWidgetData( - pair = TradingPair.BTC_USD, - change = Change( - isPositive = false, - formatted = "€ 20,326" - ), - price = "€ 20,326", - pastValues = listOf( - 1.0, - 2.0, - 3.0, - 4.0, + formatted = "+1.24%", ), + price = "75,326", + pastValues = SAMPLE_PAST_VALUES, period = GraphPeriod.ONE_DAY, ), ), - ) + ), + modifier = Modifier.fillMaxWidth() ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt index 92b98f47d5..9d89e8506f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.widgets.price import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -28,22 +28,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import to.bitkit.R -import to.bitkit.data.dto.price.Change import to.bitkit.data.dto.price.GraphPeriod -import to.bitkit.data.dto.price.PriceDTO -import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair import to.bitkit.models.widget.PricePreferences -import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -55,8 +49,6 @@ fun PriceEditScreen( navigatePreview: () -> Unit, ) { val customPreferences by viewModel.customPreferences.collectAsStateWithLifecycle() - val currentPrice by viewModel.currentPrice.collectAsStateWithLifecycle() - val allPeriodsUsd by viewModel.allPeriodsUsd.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() PriceEditContent( @@ -64,39 +56,29 @@ fun PriceEditScreen( preferences = customPreferences, onClickReset = { viewModel.resetCustomPreferences() }, onClickPreview = navigatePreview, - allPeriodsUsd = allPeriodsUsd, - priceModel = currentPrice ?: PriceDTO( - widgets = listOf(), - source = "" - ), - onClickTradingPair = { pair -> - viewModel.toggleTradingPair(pair = pair) - }, - onClickGraph = { period -> - viewModel.setPeriod(period = period) - }, + onSelectTradingPair = { viewModel.selectTradingPair(pair = it) }, + onSelectPeriod = { viewModel.setPeriod(period = it) }, isLoading = isLoading, - onClickSource = { - viewModel.toggleShowSource() - } ) } @Composable fun PriceEditContent( onBack: () -> Unit, - priceModel: PriceDTO, - allPeriodsUsd: ImmutableList, onClickReset: () -> Unit, - onClickGraph: (GraphPeriod) -> Unit, - onClickTradingPair: (TradingPair) -> Unit, + onSelectPeriod: (GraphPeriod) -> Unit, + onSelectTradingPair: (TradingPair) -> Unit, onClickPreview: () -> Unit, - onClickSource: () -> Unit, preferences: PricePreferences, isLoading: Boolean, ) { + val selectedPair = preferences.enabledPairs.firstOrNull() ?: TradingPair.BTC_USD + ScreenColumn( - modifier = Modifier.testTag("weather_edit_screen") + noBackground = true, + modifier = Modifier + .background(Colors.Gray7) + .testTag("price_edit_screen") ) { Box( modifier = Modifier @@ -112,60 +94,51 @@ fun PriceEditContent( ) { VerticalSpacer(82.dp) - BodyM( - text = stringResource(R.string.widgets__widget__edit_description).replace( - "{name}", - stringResource(R.string.widgets__price__name) - ), + Caption13Up( + text = stringResource(R.string.appwidget__price__currency), color = Colors.White64, - modifier = Modifier.testTag("edit_description") + modifier = Modifier.padding(bottom = 16.dp) ) - VerticalSpacer(32.dp) - - priceModel.widgets.map { data -> - PriceEditOptionRow( - label = data.pair.displayName, - value = data.price, - isEnabled = data.pair in preferences.enabledPairs, - onClick = { - onClickTradingPair(data.pair) - }, - testTagPrefix = data.pair.displayName, + for (pair in TradingPair.entries) { + SelectableRow( + label = pair.displayName, + isSelected = pair == selectedPair, + onClick = { onSelectTradingPair(pair) }, + testTagPrefix = pair.displayName, ) } - allPeriodsUsd.map { priceData -> - PriceChartOptionRow( - widgetData = priceData, - isEnabled = priceData.period == preferences.period, - onClick = onClickGraph, - testTagPrefix = priceData.period.value, - ) - } + VerticalSpacer(16.dp) - PriceEditOptionRow( - label = stringResource(R.string.widgets__widget__source), - value = priceModel.source, - isEnabled = preferences.showSource, - onClick = onClickSource, - testTagPrefix = "showSource", + Caption13Up( + text = stringResource(R.string.appwidget__price__timeframe), + color = Colors.White64, + modifier = Modifier.padding(vertical = 16.dp) ) + + for (period in GraphPeriod.entries) { + SelectableRow( + label = period.label(), + isSelected = period == preferences.period, + onClick = { onSelectPeriod(period) }, + testTagPrefix = period.value, + ) + } } Column { AppTopBar( titleText = stringResource(R.string.widgets__widget__edit), onBackClick = onBack, - actions = { DrawerNavIcon() }, modifier = Modifier.background( Brush.verticalGradient( colors = listOf( MaterialTheme.colorScheme.background, - Color.Transparent + Color.Transparent, ), - tileMode = TileMode.Decal - ) + tileMode = TileMode.Decal, + ), ) ) } @@ -202,10 +175,9 @@ fun PriceEditContent( } @Composable -private fun PriceEditOptionRow( +private fun SelectableRow( label: String, - value: String, - isEnabled: Boolean, + isSelected: Boolean, onClick: () -> Unit, testTagPrefix: String, ) { @@ -214,34 +186,23 @@ private fun PriceEditOptionRow( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(vertical = 16.dp) .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 14.dp) .testTag("${testTagPrefix}_setting_row") ) { BodySSB( text = label, - color = Colors.White64, + color = if (isSelected) Colors.White else Colors.White64, modifier = Modifier .weight(1f) .testTag("${testTagPrefix}_label") ) - - if (value.isNotEmpty()) { - BodySSB( - text = value, - color = Colors.White, - modifier = Modifier.testTag("${testTagPrefix}_text") - ) - } - - IconButton( - onClick = onClick, - modifier = Modifier.testTag("WidgetEditField-$testTagPrefix") - ) { + if (isSelected) { Icon( painter = painterResource(R.drawable.ic_checkmark), contentDescription = null, - tint = if (isEnabled) Colors.Brand else Colors.White50, + tint = Colors.Brand, modifier = Modifier .size(32.dp) .testTag("${testTagPrefix}_toggle_icon") @@ -256,96 +217,27 @@ private fun PriceEditOptionRow( } @Composable -private fun PriceChartOptionRow( - widgetData: PriceWidgetData, - isEnabled: Boolean, - onClick: (GraphPeriod) -> Unit, - testTagPrefix: String, -) { - Column { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = 21.dp) - .fillMaxWidth() - .testTag("${testTagPrefix}_setting_row") - ) { - ChartComponent( - widgetData = widgetData, - modifier = Modifier.weight(1f) - ) +fun GraphPeriod.label(): String = stringResource( + when (this) { + GraphPeriod.ONE_DAY -> R.string.appwidget__price__day + GraphPeriod.ONE_WEEK -> R.string.appwidget__price__week + GraphPeriod.ONE_MONTH -> R.string.appwidget__price__month + GraphPeriod.ONE_YEAR -> R.string.appwidget__price__year + }, +) - IconButton( - onClick = { onClick(widgetData.period) }, - modifier = Modifier.testTag("WidgetEditField-$testTagPrefix") - ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark), - contentDescription = null, - tint = if (isEnabled) Colors.Brand else Colors.White50, - modifier = Modifier - .size(32.dp) - .testTag("${testTagPrefix}_toggle_icon"), - ) - } - } - - HorizontalDivider( - modifier = Modifier.testTag("${testTagPrefix}_divider") - ) - } -} - -@Suppress("MagicNumber") @Preview(showSystemUi = true) @Composable private fun Preview() { AppThemeSurface { PriceEditContent( onBack = {}, - priceModel = PriceDTO( - widgets = listOf( - PriceWidgetData( - pair = TradingPair.BTC_USD, - period = GraphPeriod.ONE_DAY, - change = Change(isPositive = true, formatted = "+2.5%"), - price = "$97,500", - pastValues = listOf(95000.0, 96000.0, 95500.0, 97000.0, 97500.0) - ), - PriceWidgetData( - pair = TradingPair.BTC_EUR, - period = GraphPeriod.ONE_DAY, - change = Change(isPositive = true, formatted = "+2.3%"), - price = "€89,000", - pastValues = listOf(87000.0, 88000.0, 87500.0, 88500.0, 89000.0) - ) - ), - source = "Kraken" - ), - allPeriodsUsd = persistentListOf( - PriceWidgetData( - pair = TradingPair.BTC_USD, - period = GraphPeriod.ONE_DAY, - change = Change(isPositive = true, formatted = "+2.5%"), - price = "$97,500", - pastValues = listOf(95000.0, 96000.0, 95500.0, 97000.0, 97500.0) - ), - PriceWidgetData( - pair = TradingPair.BTC_USD, - period = GraphPeriod.ONE_WEEK, - change = Change(isPositive = true, formatted = "+5.0%"), - price = "$97,500", - pastValues = listOf(93000.0, 94000.0, 95000.0, 96000.0, 97500.0) - ) - ), onClickReset = {}, - onClickGraph = {}, - onClickTradingPair = {}, + onSelectPeriod = {}, + onSelectTradingPair = {}, onClickPreview = {}, - onClickSource = {}, preferences = PricePreferences(), - isLoading = false + isLoading = false, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt index a4f6543e3b..f334a524d4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt @@ -1,27 +1,27 @@ package to.bitkit.ui.screens.widgets.price +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.Row -import androidx.compose.foundation.layout.Spacer 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.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -31,21 +31,23 @@ import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair -import to.bitkit.ext.spaceToNewline import to.bitkit.models.widget.PricePreferences import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Headline +import to.bitkit.ui.components.Caption13Up 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.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +private const val PAGE_SMALL = 0 +private const val PAGE_WIDE = 1 +private const val PAGE_COUNT = 2 + @Composable fun PricePreviewScreen( priceViewModel: PriceViewModel, @@ -53,7 +55,6 @@ fun PricePreviewScreen( onBack: () -> Unit, navigateEditWidget: () -> Unit, ) { - val showWidgetTitles by priceViewModel.showWidgetTitles.collectAsStateWithLifecycle() val customPricePreferences by priceViewModel.customPreferences.collectAsStateWithLifecycle() val price by priceViewModel.currentPrice.collectAsStateWithLifecycle() val previewPrice by priceViewModel.previewPrice.collectAsStateWithLifecycle() @@ -76,7 +77,6 @@ fun PricePreviewScreen( onBack = onBack, isPriceWidgetEnabled = isPriceWidgetEnabled, pricePreferences = customPricePreferences, - showWidgetTitles = showWidgetTitles, priceDTO = previewPrice ?: price, onClickEdit = navigateEditWidget, onClickDelete = { @@ -86,7 +86,7 @@ fun PricePreviewScreen( onClickSave = { priceViewModel.savePreferences() }, - isLoading = isLoading + isLoading = isLoading, ) } @@ -96,59 +96,37 @@ fun PricePreviewContent( onClickEdit: () -> Unit, onClickDelete: () -> Unit, onClickSave: () -> Unit, - showWidgetTitles: Boolean, isPriceWidgetEnabled: Boolean, pricePreferences: PricePreferences, priceDTO: PriceDTO?, isLoading: Boolean, ) { ScreenColumn( - modifier = Modifier.testTag("price_preview_screen") + noBackground = true, + modifier = Modifier + .background(Colors.Gray7) + .testTag("price_preview_screen") ) { AppTopBar( - titleText = stringResource(R.string.widgets__widget__nav_title), + titleText = stringResource(R.string.widgets__price__name), onBackClick = onBack, - actions = { DrawerNavIcon() }, ) Column( modifier = Modifier .padding(horizontal = 16.dp) .weight(1f) - .verticalScroll(rememberScrollState()) - .testTag("WidgetEditScrollView") ) { - Spacer(modifier = Modifier.height(26.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .testTag("header_row"), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Headline( - text = AnnotatedString(stringResource(R.string.widgets__price__name).spaceToNewline()), - modifier = Modifier.testTag("widget_title"), - ) - Icon( - painter = painterResource(R.drawable.widget_chart_line), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier - .size(64.dp) - .testTag("widget_icon") - ) - } + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.widgets__price__description), color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("widget_description") + modifier = Modifier.testTag("widget_description") ) + VerticalSpacer(16.dp) + HorizontalDivider( modifier = Modifier.testTag("divider") ) @@ -160,149 +138,189 @@ fun PricePreviewContent( stringResource(R.string.widgets__widget__edit_default) } else { stringResource(R.string.widgets__widget__edit_custom) - } + }, ), onClick = onClickEdit, modifier = Modifier.testTag("WidgetEdit") ) - Spacer(modifier = Modifier.weight(1f)) - - Text13Up( - stringResource(R.string.common__preview), - color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("preview_label") - ) - - priceDTO?.let { dto -> - PriceCard( + if (priceDTO != null) { + WidgetCarousel( + pricePreferences = pricePreferences, + priceDTO = priceDTO, modifier = Modifier .fillMaxWidth() - .testTag("price_card"), - showWidgetTitle = showWidgetTitles, - pricePreferences = pricePreferences, - priceDTO = dto ) + } else { + Box(modifier = Modifier.weight(1f)) } } Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 21.dp, horizontal = 16.dp) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + top = 22.dp, + ) .fillMaxWidth() - .testTag("buttons_row"), - horizontalArrangement = Arrangement.spacedBy(16.dp) + .testTag("buttons_row") ) { if (isPriceWidgetEnabled) { SecondaryButton( text = stringResource(R.string.common__delete), + fullWidth = false, + onClick = onClickDelete, modifier = Modifier .weight(1f) - .testTag("WidgetDelete"), - fullWidth = false, - onClick = onClickDelete + .testTag("WidgetDelete") ) } PrimaryButton( text = stringResource(R.string.common__save), - modifier = Modifier - .weight(1f) - .testTag("WidgetSave"), fullWidth = false, isLoading = isLoading, - onClick = onClickSave + onClick = onClickSave, + modifier = Modifier + .weight(1f) + .testTag("WidgetSave") ) } } } +@Composable +private fun WidgetCarousel( + modifier: Modifier = Modifier, + pricePreferences: PricePreferences, + priceDTO: PriceDTO, +) { + val pagerState = rememberPagerState(pageCount = { PAGE_COUNT }) + + Column( + verticalArrangement = Arrangement.Center, + modifier = modifier.testTag("price_preview_carousel") + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .testTag("price_preview_pager") + ) { page -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + when (page) { + PAGE_SMALL -> PriceCardSmall( + pricePreferences = pricePreferences, + priceDTO = priceDTO, + modifier = Modifier + .width(163.dp) + .height(192.dp) + .testTag("price_card_small") + ) + + PAGE_WIDE -> PriceCard( + pricePreferences = pricePreferences, + priceDTO = priceDTO, + modifier = Modifier + .fillMaxWidth() + .testTag("price_card_wide") + ) + } + } + } + + VerticalSpacer(16.dp) + + Caption13Up( + text = stringResource( + if (pagerState.currentPage == PAGE_SMALL) { + R.string.widgets__widget__size_small + } else { + R.string.widgets__widget__size_wide + }, + ), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .testTag("widget_size_label") + ) + + VerticalSpacer(16.dp) + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .testTag("page_indicator") + ) { + repeat(PAGE_COUNT) { index -> + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(8.dp) + .background( + color = if (pagerState.currentPage == index) Colors.White else Colors.White32, + shape = CircleShape, + ), + ) + } + } + } +} + @Preview(showBackground = true) @Composable private fun Preview() { AppThemeSurface { PricePreviewContent( onBack = {}, - showWidgetTitles = true, onClickEdit = {}, onClickDelete = {}, onClickSave = {}, pricePreferences = PricePreferences(), - priceDTO = PriceDTO( - source = "Bitfinex.com", - widgets = listOf( - PriceWidgetData( - pair = TradingPair.BTC_USD, - change = Change( - isPositive = true, - formatted = "$ 20,326" - ), - price = "$20,326", - pastValues = listOf(1.0, 2.0, 3.0, 4.0), - period = GraphPeriod.ONE_DAY, - ), - PriceWidgetData( - pair = TradingPair.BTC_EUR, - change = Change( - isPositive = false, - formatted = "€ 20,326" - ), - price = "€ 20,326", - pastValues = listOf(1.0, 2.0, 3.0, 4.0), - period = GraphPeriod.ONE_DAY, - ) - ) - ), + priceDTO = SAMPLE_PRICE_DTO, isPriceWidgetEnabled = false, - isLoading = false + isLoading = false, ) } } @Preview(showBackground = true) @Composable -private fun Preview2() { +private fun PreviewWithDelete() { AppThemeSurface { PricePreviewContent( onBack = {}, - showWidgetTitles = false, onClickEdit = {}, onClickDelete = {}, onClickSave = {}, pricePreferences = PricePreferences( - enabledPairs = listOf(TradingPair.BTC_USD, TradingPair.BTC_EUR), + enabledPairs = listOf(TradingPair.BTC_USD), period = GraphPeriod.ONE_WEEK, - showSource = true - ), - priceDTO = PriceDTO( - source = "Bitfinex.com", - widgets = listOf( - PriceWidgetData( - pair = TradingPair.BTC_USD, - change = Change( - isPositive = true, - formatted = "$ 20,326" - ), - price = "$20,326", - pastValues = listOf(1.0, 2.0, 3.0, 4.0), - period = GraphPeriod.ONE_DAY, - ), - PriceWidgetData( - pair = TradingPair.BTC_EUR, - change = Change( - isPositive = false, - formatted = "€ 20,326" - ), - price = "€ 20,326", - pastValues = listOf(1.0, 2.0, 3.0, 4.0), - period = GraphPeriod.ONE_DAY, - ) - ) ), + priceDTO = SAMPLE_PRICE_DTO, isPriceWidgetEnabled = true, - isLoading = false + isLoading = false, ) } } + +private val SAMPLE_PRICE_DTO = PriceDTO( + widgets = listOf( + PriceWidgetData( + pair = TradingPair.BTC_USD, + change = Change(isPositive = true, formatted = "+1.24%"), + price = "75,326", + pastValues = listOf(1.0, 2.0, 1.5, 3.0, 2.5, 4.0), + period = GraphPeriod.ONE_DAY, + ), + ), +) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt index ffa2d4d31c..29b4c49ac9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO -import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair import to.bitkit.models.WidgetType import to.bitkit.models.widget.PricePreferences @@ -51,13 +50,6 @@ class PriceViewModel @Inject constructor( initialValue = false ) - val showWidgetTitles: StateFlow = widgetsRepo.showWidgetTitles - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), - initialValue = true - ) - val currentPrice: StateFlow = widgetsRepo.priceFlow .stateIn( scope = viewModelScope, @@ -68,8 +60,6 @@ class PriceViewModel @Inject constructor( private val _customPreferences = MutableStateFlow(PricePreferences()) val customPreferences: StateFlow = _customPreferences.asStateFlow() - private val _allPeriodsUsd = MutableStateFlow>(persistentListOf()) - val allPeriodsUsd: StateFlow> = _allPeriodsUsd.asStateFlow() private val _allPrices = MutableStateFlow>(persistentListOf()) private val _previewPrice: MutableStateFlow = MutableStateFlow(null) @@ -94,18 +84,8 @@ class PriceViewModel @Inject constructor( _previewPrice.update { _allPrices.value.firstOrNull { it.widgets.firstOrNull()?.period == period } } } - fun toggleTradingPair(pair: TradingPair) { - if (pair in _customPreferences.value.enabledPairs) { - _customPreferences.update { it.copy(enabledPairs = it.enabledPairs - pair) } - } else { - _customPreferences.update { it.copy(enabledPairs = it.enabledPairs + pair) } - } - } - - fun toggleShowSource() { - _customPreferences.update { preferences -> - preferences.copy(showSource = !preferences.showSource) - } + fun selectTradingPair(pair: TradingPair) { + _customPreferences.update { it.copy(enabledPairs = persistentListOf(pair)) } } fun resetCustomPreferences() { @@ -152,7 +132,6 @@ class PriceViewModel @Inject constructor( _isLoading.update { true } widgetsRepo.fetchAllPeriods().onSuccess { data -> _allPrices.update { data.toImmutableList() } - _allPeriodsUsd.update { data.map { priceDTO -> priceDTO.widgets.first() }.toImmutableList() } _isLoading.update { false } }.onFailure { Logger.warn("collectAllPeriodPrices error. Trying again in 1 second", context = TAG) diff --git a/app/src/main/res/drawable/appwidget_background.xml b/app/src/main/res/drawable/appwidget_background.xml new file mode 100644 index 0000000000..6754ffd182 --- /dev/null +++ b/app/src/main/res/drawable/appwidget_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/chart_preview.xml b/app/src/main/res/drawable/chart_preview.xml index ee3b28e75c..5cbc84a103 100644 --- a/app/src/main/res/drawable/chart_preview.xml +++ b/app/src/main/res/drawable/chart_preview.xml @@ -1,22 +1,15 @@ - - - - - - - - + android:viewportHeight="48"> + + + + diff --git a/app/src/main/res/layout/appwidget_preview_price.xml b/app/src/main/res/layout/appwidget_preview_price.xml index 69637f7430..91448622b7 100644 --- a/app/src/main/res/layout/appwidget_preview_price.xml +++ b/app/src/main/res/layout/appwidget_preview_price.xml @@ -12,94 +12,36 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_vertical" - android:orientation="horizontal" - android:paddingVertical="2dp"> + android:orientation="horizontal"> - - - - - - - - - - - + android:text="+1.24%" + android:textColor="#FF75BF72" + android:textSize="22sp" + android:textStyle="bold" /> - - - - - - - - + android:layout_marginTop="4dp" + android:text="$ 75,326" + android:textColor="#FFFFFFFF" + android:textSize="34sp" + android:textStyle="bold" /> + + 250dp + 110dp + 110dp + 110dp + diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml new file mode 100644 index 0000000000..7f3f94908d --- /dev/null +++ b/app/src/main/res/values/integers.xml @@ -0,0 +1,5 @@ + + + 4 + 2 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60b0b12f47..4fbad6e2fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,9 +1,13 @@ Loading… + Currency + Day Bitcoin price tracker - Time period - Trading pairs + Month + Timeframe + Week + Year Store your bitcoin Back up Buy some bitcoin @@ -1170,6 +1174,8 @@ Default Please select which fields you want to display in the {name} widget. Widget + Small + Wide Source Widgets diff --git a/app/src/main/res/xml/appwidget_info_price.xml b/app/src/main/res/xml/appwidget_info_price.xml index 28be394053..94fe99672d 100644 --- a/app/src/main/res/xml/appwidget_info_price.xml +++ b/app/src/main/res/xml/appwidget_info_price.xml @@ -1,13 +1,17 @@ + android:updatePeriodMillis="0" + tools:targetApi="31" />