diff --git a/AGENTS.md b/AGENTS.md index 585ea46e8d..28d0cb283b 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/app/build.gradle.kts b/app/build.gradle.kts index f36e188837..91a377e23c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -286,6 +286,9 @@ dependencies { // WorkManager implementation(libs.hilt.work) implementation(libs.work.runtime.ktx) + // Glance - AppWidgets + implementation(libs.glance.appwidget) + implementation(libs.glance.material3) // Ktor - Networking implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c328418e84..ef4b31e795 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -177,6 +177,33 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt new file mode 100644 index 0000000000..1bbbcd6b4e --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt @@ -0,0 +1,21 @@ +package to.bitkit.appwidget + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.data.dto.price.GraphPeriod +import to.bitkit.data.dto.price.PriceDTO +import to.bitkit.data.widgets.PriceService +import to.bitkit.di.IoDispatcher +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppWidgetDataRepository @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val priceService: PriceService, +) { + suspend fun fetchPriceData(period: GraphPeriod = GraphPeriod.ONE_DAY): Result = + withContext(ioDispatcher) { + priceService.fetchData(period) + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt new file mode 100644 index 0000000000..88b8e865ce --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt @@ -0,0 +1,82 @@ +package to.bitkit.appwidget + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import to.bitkit.appwidget.model.AppWidgetData +import to.bitkit.appwidget.model.AppWidgetEntry +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.data.dto.price.GraphPeriod +import to.bitkit.data.dto.price.PriceDTO +import to.bitkit.data.serializers.AppWidgetDataSerializer +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.appWidgetDataStore: DataStore by dataStore( + fileName = "appwidget_data.json", + serializer = AppWidgetDataSerializer, +) + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AppWidgetEntryPoint { + fun appWidgetPreferencesStore(): AppWidgetPreferencesStore +} + +@Singleton +class AppWidgetPreferencesStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val store = context.appWidgetDataStore + + val data: Flow = store.data + + suspend fun registerWidget(appWidgetId: Int, type: AppWidgetType) { + store.updateData { data -> + if (data.entries.any { it.appWidgetId == appWidgetId }) return@updateData data + data.copy(entries = data.entries + AppWidgetEntry(appWidgetId = appWidgetId, type = type)) + } + } + + suspend fun unregisterWidget(appWidgetId: Int) { + store.updateData { data -> + data.copy(entries = data.entries.filter { it.appWidgetId != appWidgetId }) + } + } + + suspend fun getEntry(appWidgetId: Int): AppWidgetEntry? = + store.data.first().entries.find { it.appWidgetId == appWidgetId } + + suspend fun updateEntry(appWidgetId: Int, transform: (AppWidgetEntry) -> AppWidgetEntry) { + store.updateData { data -> + data.copy( + entries = data.entries.map { + if (it.appWidgetId == appWidgetId) transform(it) else it + }, + ) + } + } + + suspend fun getActiveWidgetTypes(): Set = + store.data.first().entries.map { it.type }.toSet() + + suspend fun getActivePricePeriods(): Set = + store.data.first().entries + .filter { it.type == AppWidgetType.PRICE } + .map { it.pricePreferences.period } + .toSet() + + fun hasWidgetsOfType(type: AppWidgetType): Flow = + data.map { it.entries.any { entry -> entry.type == type } } + + suspend fun cachePriceData(period: GraphPeriod, price: PriceDTO) { + store.updateData { it.copy(cachedPrices = it.cachedPrices + (period to price)) } + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt new file mode 100644 index 0000000000..aaa8d1e95f --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt @@ -0,0 +1,92 @@ +package to.bitkit.appwidget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.updateAll +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.ui.price.PriceGlanceReceiver +import to.bitkit.appwidget.ui.price.PriceGlanceWidget +import to.bitkit.utils.Logger +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@HiltWorker +class AppWidgetRefreshWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val dataRepository: AppWidgetDataRepository, + private val preferencesStore: AppWidgetPreferencesStore, +) : CoroutineWorker(appContext, workerParams) { + + companion object { + private const val TAG = "AppWidgetRefreshWorker" + private const val WORK_NAME = "appwidget_refresh" + + fun enqueue(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder(15.minutes.toJavaDuration()) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + fun cancelIfNoWidgets(context: Context) { + val manager = AppWidgetManager.getInstance(context) + val hasAny = AppWidgetType.entries.any { type -> + manager.getAppWidgetIds(ComponentName(context, receiverClassFor(type))).isNotEmpty() + } + if (!hasAny) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } + + private fun receiverClassFor(type: AppWidgetType): Class = when (type) { + AppWidgetType.PRICE -> PriceGlanceReceiver::class.java + } + } + + override suspend fun doWork(): Result { + val activeTypes = preferencesStore.getActiveWidgetTypes() + if (activeTypes.isEmpty()) return Result.success() + + Logger.debug("Refreshing data for widget types: '$activeTypes'", context = TAG) + + for (type in activeTypes) { + when (type) { + AppWidgetType.PRICE -> { + val periods = preferencesStore.getActivePricePeriods() + periods.forEach { period -> + dataRepository.fetchPriceData(period) + .onSuccess { preferencesStore.cachePriceData(period, it) } + .onFailure { + Logger.warn("Failed to refresh price for '$period'", it, context = TAG) + } + } + PriceGlanceWidget().updateAll(appContext) + } + } + } + + return Result.success() + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt new file mode 100644 index 0000000000..111b387275 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt @@ -0,0 +1,66 @@ +package to.bitkit.appwidget.config + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.glance.appwidget.updateAll +import dagger.hilt.android.AndroidEntryPoint +import to.bitkit.appwidget.AppWidgetRefreshWorker +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.ui.price.PriceGlanceWidget +import to.bitkit.ui.theme.AppThemeSurface + +@AndroidEntryPoint +class AppWidgetConfigActivity : ComponentActivity() { + + companion object { + const val EXTRA_WIDGET_TYPE = "extra_widget_type" + } + + private val viewModel: AppWidgetConfigViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + setResult(RESULT_CANCELED) + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + val typeName = intent?.getStringExtra(EXTRA_WIDGET_TYPE) + val type = typeName?.let { runCatching { AppWidgetType.valueOf(it) }.getOrNull() } + ?: AppWidgetType.PRICE + + if (savedInstanceState == null) viewModel.init(appWidgetId, type) + + setContent { + AppThemeSurface { + AppWidgetConfigScreen( + viewModel = viewModel, + onConfirm = { + PriceGlanceWidget().updateAll(this@AppWidgetConfigActivity) + AppWidgetRefreshWorker.enqueue(this@AppWidgetConfigActivity) + val result = Intent().putExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + appWidgetId, + ) + setResult(Activity.RESULT_OK, result) + finish() + }, + onCancel = { finish() }, + ) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt new file mode 100644 index 0000000000..d871fad19f --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt @@ -0,0 +1,173 @@ +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 +import androidx.compose.foundation.layout.fillMaxWidth +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.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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.data.dto.price.GraphPeriod +import to.bitkit.data.dto.price.TradingPair +import to.bitkit.ext.label +import to.bitkit.models.widget.PricePreferences +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.theme.Colors + +@Composable +fun AppWidgetConfigScreen( + viewModel: AppWidgetConfigViewModel, + onConfirm: suspend () -> Unit, + onCancel: () -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + when (state.type) { + AppWidgetType.PRICE -> Content( + state = state, + onSelectPair = { viewModel.selectPricePair(it) }, + onSelectPeriod = { viewModel.selectPricePeriod(it) }, + onReset = { viewModel.resetPreferences() }, + onSave = { viewModel.saveAndFinish(onConfirm) }, + onCancel = onCancel, + ) + } +} + +@Composable +private fun Content( + state: AppWidgetConfigUiState, + onSelectPair: (TradingPair) -> Unit, + onSelectPeriod: (GraphPeriod) -> Unit, + onReset: () -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + val prefs = state.pricePreferences + val selectedPair = prefs.enabledPairs.firstOrNull() ?: TradingPair.BTC_USD + + ScreenColumn( + noBackground = true, + modifier = Modifier.background(Colors.Gray7) + ) { + AppTopBar( + titleText = stringResource(R.string.widgets__price__name), + onBackClick = onCancel, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + VerticalSpacer(16.dp) + + Caption13Up( + text = stringResource(R.string.appwidget__price__currency), + color = Colors.White64, + modifier = Modifier.padding(bottom = 16.dp) + ) + + for (pair in TradingPair.entries) { + SelectableRow( + label = pair.displayName, + isSelected = pair == selectedPair, + onClick = { onSelectPair(pair) }, + ) + } + + VerticalSpacer(16.dp) + 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 == prefs.period, + onClick = { onSelectPeriod(period) }, + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SecondaryButton( + text = stringResource(R.string.common__reset), + enabled = prefs != PricePreferences(), + fullWidth = false, + onClick = onReset, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = stringResource(R.string.common__save), + isLoading = state.isSaving, + enabled = !state.isSaving, + fullWidth = false, + onClick = onSave, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun SelectableRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 14.dp) + ) { + BodySSB( + text = label, + color = if (isSelected) Colors.White else Colors.White64, + modifier = Modifier.weight(1f) + ) + if (isSelected) { + Icon( + painter = painterResource(R.drawable.ic_checkmark), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(32.dp) + ) + } + } + HorizontalDivider() + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt new file mode 100644 index 0000000000..9d36446b6e --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt @@ -0,0 +1,101 @@ +package to.bitkit.appwidget.config + +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 +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.appwidget.AppWidgetDataRepository +import to.bitkit.appwidget.AppWidgetPreferencesStore +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.model.HomePricePreferences +import to.bitkit.data.dto.price.GraphPeriod +import to.bitkit.data.dto.price.TradingPair +import to.bitkit.models.widget.PricePreferences +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class AppWidgetConfigViewModel @Inject constructor( + private val preferencesStore: AppWidgetPreferencesStore, + private val dataRepository: AppWidgetDataRepository, +) : ViewModel() { + + companion object { + private const val TAG = "AppWidgetConfigViewModel" + } + + private val _uiState = MutableStateFlow(AppWidgetConfigUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun init(appWidgetId: Int, type: AppWidgetType) { + viewModelScope.launch { + val entry = preferencesStore.getEntry(appWidgetId) + + _uiState.update { + it.copy( + appWidgetId = appWidgetId, + type = type, + pricePreferences = entry?.pricePreferences?.toInApp() ?: PricePreferences(), + ) + } + } + } + + fun selectPricePair(pair: TradingPair) { + _uiState.update { + it.copy(pricePreferences = it.pricePreferences.copy(enabledPairs = persistentListOf(pair))) + } + } + + fun selectPricePeriod(period: GraphPeriod) { + _uiState.update { + it.copy(pricePreferences = it.pricePreferences.copy(period = period)) + } + } + + fun resetPreferences() { + _uiState.update { it.copy(pricePreferences = PricePreferences()) } + } + + fun saveAndFinish(onComplete: suspend () -> Unit) { + viewModelScope.launch { + val appWidgetId = _uiState.value.appWidgetId + val pricePreferences = _uiState.value.pricePreferences + _uiState.update { it.copy(isSaving = true) } + preferencesStore.registerWidget(appWidgetId, AppWidgetType.PRICE) + preferencesStore.updateEntry(appWidgetId) { entry -> + entry.copy(pricePreferences = pricePreferences.toHome()) + } + val period = pricePreferences.period ?: GraphPeriod.ONE_DAY + dataRepository.fetchPriceData(period) + .onSuccess { preferencesStore.cachePriceData(period, it) } + .onFailure { Logger.warn("Failed to fetch initial price data", it, context = TAG) } + onComplete() + _uiState.update { it.copy(isSaving = false) } + } + } +} + +@Stable +data class AppWidgetConfigUiState( + val appWidgetId: Int = -1, + val type: AppWidgetType = AppWidgetType.PRICE, + val pricePreferences: PricePreferences = PricePreferences(), + val isSaving: Boolean = false, +) + +private fun HomePricePreferences.toInApp() = PricePreferences( + enabledPairs = enabledPairs, + period = period, +) + +private fun PricePreferences.toHome() = HomePricePreferences( + enabledPairs = enabledPairs, + period = period ?: GraphPeriod.ONE_DAY, +) diff --git a/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt new file mode 100644 index 0000000000..0aedb2ed04 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt @@ -0,0 +1,33 @@ +package to.bitkit.appwidget.model + +import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable +import to.bitkit.data.dto.price.GraphPeriod +import to.bitkit.data.dto.price.PriceDTO +import to.bitkit.data.dto.price.TradingPair + +enum class AppWidgetType { + PRICE, +} + +@Stable +@Serializable +data class AppWidgetEntry( + val appWidgetId: Int, + val type: AppWidgetType, + val pricePreferences: HomePricePreferences = HomePricePreferences(), +) + +@Stable +@Serializable +data class HomePricePreferences( + val enabledPairs: List = listOf(TradingPair.BTC_USD), + val period: GraphPeriod = GraphPeriod.ONE_DAY, +) + +@Stable +@Serializable +data class AppWidgetData( + val entries: List = emptyList(), + val cachedPrices: Map = emptyMap(), +) 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/GlanceSpacer.kt b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceSpacer.kt new file mode 100644 index 0000000000..a7a571ea2e --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceSpacer.kt @@ -0,0 +1,24 @@ +package to.bitkit.appwidget.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.glance.GlanceModifier +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.width + +@Composable +fun VerticalSpacer(height: Dp) { + Spacer(modifier = GlanceModifier.height(height)) +} + +@Composable +fun HorizontalSpacer(width: Dp) { + Spacer(modifier = GlanceModifier.width(width)) +} + +@Composable +fun FillWidth() { + Spacer(modifier = GlanceModifier.fillMaxWidth()) +} diff --git a/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceText.kt b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceText.kt new file mode 100644 index 0000000000..b0b723a00d --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceText.kt @@ -0,0 +1,71 @@ +package to.bitkit.appwidget.ui.components + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import to.bitkit.appwidget.ui.theme.GlanceTextStyles + +@Composable +fun Subtitle( + text: String, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider? = null, + maxLines: Int = Int.MAX_VALUE, +) { + Text(text = text, style = GlanceTextStyles.subtitle.withColor(color), maxLines = maxLines, modifier = modifier) +} + +@Composable +fun BodyMSB( + text: String, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider? = null, + maxLines: Int = Int.MAX_VALUE, +) { + Text(text = text, style = GlanceTextStyles.bodyMSB.withColor(color), maxLines = maxLines, modifier = modifier) +} + +@Composable +fun BodySSB( + text: String, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider? = null, + maxLines: Int = Int.MAX_VALUE, +) { + Text(text = text, style = GlanceTextStyles.bodySSB.withColor(color), maxLines = maxLines, modifier = modifier) +} + +@Composable +fun BodySB( + text: String, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider? = null, + maxLines: Int = Int.MAX_VALUE, +) { + Text(text = text, style = GlanceTextStyles.bodySB.withColor(color), maxLines = maxLines, modifier = modifier) +} + +@Composable +fun CaptionB( + text: String, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider? = null, + maxLines: Int = Int.MAX_VALUE, +) { + Text(text = text, style = GlanceTextStyles.captionB.withColor(color), maxLines = maxLines, modifier = modifier) +} + +@Composable +fun FootnoteM( + text: String, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider? = null, + maxLines: Int = Int.MAX_VALUE, +) { + Text(text = text, style = GlanceTextStyles.footnoteM.withColor(color), maxLines = maxLines, modifier = modifier) +} + +private fun TextStyle.withColor(color: ColorProvider?): TextStyle = + if (color != null) copy(color = color) else this 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 new file mode 100644 index 0000000000..37aed3364e --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceWidgetScaffold.kt @@ -0,0 +1,32 @@ +package to.bitkit.appwidget.ui.components + +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.background +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import to.bitkit.R + +@Composable +fun GlanceWidgetScaffold( + onClick: Intent? = null, + content: @Composable () -> Unit, +) { + val modifier = GlanceModifier + .fillMaxSize() + .background(ImageProvider(R.drawable.appwidget_background)) + .padding(16.dp) + .let { mod -> + if (onClick != null) mod.clickable(actionStartActivity(onClick)) else mod + } + + Column(modifier = modifier) { + content() + } +} 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 new file mode 100644 index 0000000000..eba40c3608 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/price/LineChartBitmap.kt @@ -0,0 +1,70 @@ +package to.bitkit.appwidget.ui.price + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import androidx.annotation.ColorInt +import androidx.core.graphics.createBitmap + +private const val SMOOTHING = 0.2f + +fun renderLineChartBitmap( + values: List, + width: Int, + height: Int, + @ColorInt lineColor: Int, +): Bitmap { + val bitmap = createBitmap(width, height) + if (values.size < 2) return bitmap + + val canvas = Canvas(bitmap) + val minValue = values.min() + val maxValue = values.max() + val range = (maxValue - minValue).coerceAtLeast(1.0) + + val padding = 4f + val drawWidth = width - padding * 2 + val drawHeight = height - padding * 2 + val stepX = drawWidth / (values.size - 1) + + val points = values.mapIndexed { i, v -> + val x = padding + i * stepX + val y = padding + drawHeight - ((v - minValue) / range * drawHeight).toFloat() + x to y + } + + val linePath = buildSmoothPath(points, yMin = padding, yMax = padding + drawHeight) + + val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = lineColor + style = Paint.Style.STROKE + strokeWidth = 3f + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + canvas.drawPath(linePath, linePaint) + + return bitmap +} + +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)] + val p1 = points[i] + val p2 = points[i + 1] + 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).coerceIn(yMin, yMax) + val cp2x = p2.first - (p3.first - p1.first) * 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 new file mode 100644 index 0000000000..f91ebad969 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceContent.kt @@ -0,0 +1,152 @@ +package to.bitkit.appwidget.ui.price + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.cornerRadius +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.ContentScale +import androidx.glance.layout.HeightModifier +import androidx.glance.layout.Row +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.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.components.VerticalSpacer +import to.bitkit.appwidget.ui.theme.GlanceTextStyles +import to.bitkit.data.dto.price.PriceWidgetData +import to.bitkit.ui.theme.Colors +import java.util.Locale + +@Suppress("RestrictedApi") +@Composable +fun PriceGlanceContent( + widget: PriceWidgetData?, + entry: AppWidgetEntry, + chartBitmap: Bitmap? = null, +) { + val context = LocalContext.current + 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) + putExtra(AppWidgetConfigActivity.EXTRA_WIDGET_TYPE, AppWidgetType.PRICE.name) + } + + GlanceWidgetScaffold(onClick = configIntent) { + if (widget == null) { + CaptionB(text = context.getString(R.string.appwidget__loading)) + return@GlanceWidgetScaffold + } + + 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 WideContent(widget: PriceWidgetData, chartBitmap: Bitmap?) { + val changeColor = if (widget.change.isPositive) Colors.Green else Colors.Red + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier.fillMaxWidth() + ) { + Text( + text = "${widget.pair.displayName} ${widget.period.value}".uppercase(Locale.ENGLISH), + style = GlanceTextStyles.captionB, + modifier = GlanceModifier.then(WidthModifier(Dimension.Expand)) + ) + HorizontalSpacer(16.dp) + Text( + text = widget.change.formatted, + 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 + + Row(modifier = GlanceModifier.fillMaxWidth()) { + Text( + text = widget.pair.displayName.uppercase(Locale.ENGLISH), + style = GlanceTextStyles.captionB, + modifier = GlanceModifier.then(WidthModifier(Dimension.Expand)) + ) + Text( + text = widget.period.value.uppercase(Locale.ENGLISH), + style = GlanceTextStyles.captionB, + ) + } + 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) + ) + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceReceiver.kt new file mode 100644 index 0000000000..880d28001a --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceReceiver.kt @@ -0,0 +1,40 @@ +package to.bitkit.appwidget.ui.price + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import to.bitkit.appwidget.AppWidgetEntryPoint +import to.bitkit.appwidget.AppWidgetRefreshWorker + +class PriceGlanceReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = PriceGlanceWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + AppWidgetRefreshWorker.enqueue(context) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + val pendingResult = goAsync() + val store = EntryPointAccessors + .fromApplication(context, AppWidgetEntryPoint::class.java) + .appWidgetPreferencesStore() + CoroutineScope(Dispatchers.IO).launch { + try { + appWidgetIds.forEach { store.unregisterWidget(it) } + } finally { + pendingResult.finish() + } + } + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + AppWidgetRefreshWorker.cancelIfNoWidgets(context) + } +} 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 new file mode 100644 index 0000000000..1d1dbb58a3 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceWidget.kt @@ -0,0 +1,83 @@ +package to.bitkit.appwidget.ui.price + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.toArgb +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 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() { + + companion object { + private const val CHART_WIDTH = 600 + private const val CHART_HEIGHT = 200 + } + + override val sizeMode = SizeMode.Responsive( + setOf(GlanceLayoutDimens.COMPACT_WIDGET_SIZE, GlanceLayoutDimens.WIDE_WIDGET_SIZE), + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val store = EntryPointAccessors + .fromApplication(context, AppWidgetEntryPoint::class.java) + .appWidgetPreferencesStore() + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + + provideContent { + val data by store.data.collectAsState(initial = AppWidgetData()) + val entry = data.entries.find { it.appWidgetId == appWidgetId } + ?: AppWidgetEntry(appWidgetId = appWidgetId, type = AppWidgetType.PRICE) + val price = data.cachedPrices[entry.pricePreferences.period] + val widget = remember(price, entry.pricePreferences) { + resolveWidget(price, entry) + } + val chartBitmap = remember(widget) { + widget?.let { buildChartBitmap(it) } + } + + PriceGlanceContent( + widget = widget, + entry = entry, + chartBitmap = chartBitmap, + ) + } + } + + 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 (widget.change.isPositive) { + Colors.Green.toArgb() + } else { + Colors.Red.toArgb() + } + + return renderLineChartBitmap( + 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 new file mode 100644 index 0000000000..b8705ff13d --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceColors.kt @@ -0,0 +1,10 @@ +package to.bitkit.appwidget.ui.theme + +import androidx.glance.color.ColorProvider +import to.bitkit.ui.theme.Colors + +object GlanceColors { + 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 new file mode 100644 index 0000000000..e37b669a6b --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceTextStyles.kt @@ -0,0 +1,17 @@ +package to.bitkit.appwidget.ui.theme + +import androidx.compose.ui.unit.sp +import androidx.glance.text.FontWeight +import androidx.glance.text.TextStyle + +object GlanceTextStyles { + val subtitle = TextStyle(fontSize = 17.sp, fontWeight = FontWeight.Bold, color = GlanceColors.textPrimary) + val bodyMSB = TextStyle(fontSize = 17.sp, fontWeight = FontWeight.Medium, color = GlanceColors.textPrimary) + 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 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/serializers/AppWidgetDataSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/AppWidgetDataSerializer.kt new file mode 100644 index 0000000000..62b3b08fbd --- /dev/null +++ b/app/src/main/java/to/bitkit/data/serializers/AppWidgetDataSerializer.kt @@ -0,0 +1,27 @@ +package to.bitkit.data.serializers + +import androidx.datastore.core.Serializer +import to.bitkit.appwidget.model.AppWidgetData +import to.bitkit.di.json +import to.bitkit.utils.Logger +import java.io.InputStream +import java.io.OutputStream + +object AppWidgetDataSerializer : Serializer { + private const val TAG = "AppWidgetDataSerializer" + + override val defaultValue: AppWidgetData = AppWidgetData() + + override suspend fun readFrom(input: InputStream): AppWidgetData { + return runCatching { + json.decodeFromString(input.readBytes().decodeToString()) + }.getOrElse { + Logger.error("Failed to deserialize AppWidgetData", it, context = TAG) + defaultValue + } + } + + override suspend fun writeTo(t: AppWidgetData, output: OutputStream) { + output.write(json.encodeToString(t).encodeToByteArray()) + } +} 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 3b859bad02..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,18 +36,20 @@ 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 + fetchData(period).getOrThrow() + } + suspend fun fetchData(period: GraphPeriod): Result = runCatching { val widgets = TradingPair.entries.mapNotNull { pair -> runCatching { fetchPairData(pair = pair, period = period) } .onFailure { Logger.warn(e = it, msg = "Failed to fetch ${pair.ticker}", context = TAG) } .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) } @@ -59,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/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 1992e08cd0..629b848423 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -17,8 +17,8 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.ActivityRepo import to.bitkit.models.msatCeilOf +import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.Logger import javax.inject.Inject diff --git a/app/src/main/java/to/bitkit/ext/GraphPeriod.kt b/app/src/main/java/to/bitkit/ext/GraphPeriod.kt new file mode 100644 index 0000000000..d8e6d38171 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/GraphPeriod.kt @@ -0,0 +1,18 @@ +package to.bitkit.ext + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import to.bitkit.R +import to.bitkit.data.dto.price.GraphPeriod + +@StringRes +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 +} + +@Composable +fun GraphPeriod.label(): String = stringResource(labelRes()) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index fe9a86efcb..6572848a8d 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -26,7 +26,6 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.toUserMessage import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.msatCeilOf import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived import to.bitkit.models.BlocktankNotificationType.incomingHtlc import to.bitkit.models.BlocktankNotificationType.mutualClose @@ -36,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails +import to.bitkit.models.msatCeilOf import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo diff --git a/app/src/main/java/to/bitkit/models/widget/PricePreferences.kt b/app/src/main/java/to/bitkit/models/widget/PricePreferences.kt index 49d5fd0134..28a436c641 100644 --- a/app/src/main/java/to/bitkit/models/widget/PricePreferences.kt +++ b/app/src/main/java/to/bitkit/models/widget/PricePreferences.kt @@ -12,5 +12,4 @@ data class PricePreferences( TradingPair.BTC_USD ), val period: GraphPeriod? = GraphPeriod.ONE_DAY, - val showSource: Boolean = false ) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 199e2ff3e5..dde2ee0a44 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -32,8 +32,8 @@ import to.bitkit.ext.toHex import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState -import to.bitkit.models.msatFloorOf import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.msatFloorOf import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 4349eced4a..527dff93d3 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -72,11 +72,11 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats -import to.bitkit.models.msatFloorOf import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.addressTypeFromAddress +import to.bitkit.models.msatFloorOf import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 568856ae13..0745b713d5 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1123,14 +1123,10 @@ class MigrationService @Inject constructor( else -> GraphPeriod.ONE_DAY } - val showSource = priceJson["showSource"]?.jsonPrimitive?.content - ?.toBooleanStrictOrNull() ?: false - widgetsStore.updatePricePreferences( PricePreferences( enabledPairs = selectedPairs, period = period, - showSource = showSource ) ) }.onFailure { diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 1b6f708408..43b87cd914 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -50,11 +50,11 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString import to.bitkit.ext.uri -import to.bitkit.models.msatFloorOf import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer import to.bitkit.models.alias import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index ef8d3e0cf2..94aaa9340f 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -60,6 +60,25 @@ fun Display( ) } +@Composable +fun Display34( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, +) { + Text( + text = text, + style = AppTextStyles.Display34.merge( + color = color, + ), + maxLines = maxLines, + overflow = overflow, + modifier = modifier + ) +} + @Composable fun Headline( text: AnnotatedString, 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 0553c78fb7..f5cb9b614b 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 @@ -792,7 +792,6 @@ private fun Widgets( WidgetType.PRICE -> { homeUiState.currentPrice?.run { PriceCard( - showWidgetTitle = homeUiState.showWidgetTitles, pricePreferences = homeUiState.pricePreferences, priceDTO = homeUiState.currentPrice, modifier = Modifier @@ -990,7 +989,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..8aeaba439c 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,19 +2,16 @@ 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.runtime.Composable @@ -22,11 +19,11 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import ir.ehsannarmani.compose_charts.LineChart @@ -44,131 +41,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.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display34 +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.Title 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, + modifier: Modifier = Modifier, ) { + val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { + priceDTO.resolveWidget(pricePreferences) + } ?: 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) + Title( + text = widgetData.change.formatted, + color = if (widgetData.change.isPositive) Colors.Green else Colors.Red, + 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 } - } + Display34( + text = "${widgetData.pair.symbol} ${widgetData.price}", + color = Colors.White, + modifier = Modifier + .fillMaxWidth() + .testTag("price_card_pair_price_${widgetData.pair}") + ) + + ChartComponent( + widgetData = widgetData, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .testTag("price_card_chart") + ) + } + } +} - enabledPairs.map { widgetData -> +@Composable +fun PriceCardSmall( + pricePreferences: PricePreferences, + priceDTO: PriceDTO, + modifier: Modifier = Modifier, +) { + val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { + priceDTO.resolveWidget(pricePreferences) + } ?: 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, + Title( + text = "${widgetData.pair.symbol} ${widgetData.price}", + color = Colors.White, modifier = Modifier .fillMaxWidth() - .padding(top = 16.63.dp) - .testTag("price_card_chart") + .testTag("price_card_small_pair_price_${widgetData.pair}") + ) + BodySSB( + text = widgetData.change.formatted, + color = if (widgetData.change.isPositive) Colors.Green else Colors.Red, + 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") + ) } } } +private fun PriceDTO.resolveWidget(prefs: PricePreferences): PriceWidgetData? = + widgets.firstOrNull { it.pair in prefs.enabledPairs } ?: widgets.firstOrNull() + @Composable fun ChartComponent( widgetData: PriceWidgetData, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val baseColor = if (widgetData.change.isPositive) Colors.Green else Colors.Red @@ -180,107 +192,83 @@ 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 - ), + 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_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..fa52e9e21d 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,17 @@ 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.ext.label 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 +50,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 +57,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 +95,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 +176,9 @@ fun PriceEditContent( } @Composable -private fun PriceEditOptionRow( +private fun SelectableRow( label: String, - value: String, - isEnabled: Boolean, + isSelected: Boolean, onClick: () -> Unit, testTagPrefix: String, ) { @@ -214,34 +187,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") @@ -255,97 +217,18 @@ 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) - ) - - 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..1d458dfd72 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,24 @@ 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.FillHeight 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 +56,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 +78,6 @@ fun PricePreviewScreen( onBack = onBack, isPriceWidgetEnabled = isPriceWidgetEnabled, pricePreferences = customPricePreferences, - showWidgetTitles = showWidgetTitles, priceDTO = previewPrice ?: price, onClickEdit = navigateEditWidget, onClickDelete = { @@ -86,7 +87,7 @@ fun PricePreviewScreen( onClickSave = { priceViewModel.savePreferences() }, - isLoading = isLoading + isLoading = isLoading, ) } @@ -96,59 +97,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 +139,190 @@ 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 + .weight(1f) ) + } else { + FillHeight() } } 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( + pricePreferences: PricePreferences, + priceDTO: PriceDTO, + modifier: Modifier = Modifier, +) { + 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/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 0b93407aca..a41243741d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -60,8 +60,8 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.setClipboardText -import to.bitkit.models.msatFloorOf import to.bitkit.models.Toast +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 89f42eb0f0..4c19139542 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -47,8 +47,8 @@ import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails -import to.bitkit.models.msatFloorOf import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB diff --git a/app/src/main/java/to/bitkit/ui/theme/Type.kt b/app/src/main/java/to/bitkit/ui/theme/Type.kt index 73cde025fe..0c3c7e3469 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Type.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Type.kt @@ -50,6 +50,13 @@ object AppTextStyles { letterSpacing = (-1).sp, fontFamily = InterFontFamily, ) + val Display34 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 34.sp, + letterSpacing = (-1).sp, + fontFamily = InterFontFamily, + ) val Headline = TextStyle( fontWeight = FontWeight.Black, fontSize = 30.sp, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7c1ad3b4ec..2d2d40384d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2502,6 +2502,7 @@ class AppViewModel @Inject constructor( } fun handleDeeplinkIntent(intent: Intent) { + if (intent.action != Intent.ACTION_VIEW) return intent.data?.let { uri -> Logger.debug("Received deeplink: $uri") processDeeplink(uri) 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 new file mode 100644 index 0000000000..5cbc84a103 --- /dev/null +++ b/app/src/main/res/drawable/chart_preview.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/layout/appwidget_preview_price.xml b/app/src/main/res/layout/appwidget_preview_price.xml new file mode 100644 index 0000000000..91448622b7 --- /dev/null +++ b/app/src/main/res/layout/appwidget_preview_price.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/glance_default_loading_layout.xml b/app/src/main/res/layout/glance_default_loading_layout.xml new file mode 100644 index 0000000000..882ac72d0f --- /dev/null +++ b/app/src/main/res/layout/glance_default_loading_layout.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f30d09dd84..97308bd87e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,7 @@ #FF4400 + #FF2A2A2A #FFF1EE #EF886A #03DAC5 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..96bba3e1ee --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,7 @@ + + + 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 9a7ae2ec1f..d940968f34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,13 @@ + Loading… + Currency + Day + Bitcoin price tracker + Month + Timeframe + Week + Year Store your bitcoin Back up Buy some bitcoin @@ -1167,6 +1175,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 new file mode 100644 index 0000000000..94fe99672d --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_price.xml @@ -0,0 +1,17 @@ + + diff --git a/changelog.d/next/895.added.md b/changelog.d/next/895.added.md new file mode 100644 index 0000000000..e4b308242c --- /dev/null +++ b/changelog.d/next/895.added.md @@ -0,0 +1 @@ +Home screen widgets foundation with Glance, including price widget as the first implementation diff --git a/changelog.d/next/914.changed.md b/changelog.d/next/914.changed.md new file mode 100644 index 0000000000..35c8aac43e --- /dev/null +++ b/changelog.d/next/914.changed.md @@ -0,0 +1 @@ +Redesign price widget with v61 wide and compact layouts, new preview and edit screens, and tap-to-edit behavior diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27084a1ecf..0a07fa7f40 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.13.2" camera = "1.5.2" coil = "3.2.0" detekt = "1.23.8" +glance = "1.2.0-rc01" hilt = "2.57.2" hiltAndroidx = "1.3.0" kotlin = "2.2.21" @@ -44,6 +45,8 @@ datastore-preferences = { module = "androidx.datastore:datastore-preferences", v detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-compose-rules = { module = "io.nlopez.compose.rules:detekt", version = "0.5.3" } firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.8.0" } +glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } +glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } firebase-messaging = { module = "com.google.firebase:firebase-messaging" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }