diff --git a/CHANGELOG.md b/CHANGELOG.md
index 299beaf0f..5cfa6925a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Home screen widgets foundation with Glance, including price widget as the first implementation #895
+
## [2.2.0] - 2026-04-07
### Fixed
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f36e18883..91a377e23 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 c328418e8..ef4b31e79 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 000000000..1bbbcd6b4
--- /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 000000000..88b8e865c
--- /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 000000000..aaa8d1e95
--- /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 000000000..049d383e2
--- /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
+
+ 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 000000000..b1af62ac2
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt
@@ -0,0 +1,175 @@
+package to.bitkit.appwidget.config
+
+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.material3.IconButton
+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.models.widget.PricePreferences
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodySSB
+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 -> PriceConfigContent(
+ state = state,
+ onTogglePair = { viewModel.togglePricePair(it) },
+ onSelectPeriod = { viewModel.selectPricePeriod(it) },
+ onReset = { viewModel.resetPreferences() },
+ onSave = { viewModel.saveAndFinish(onConfirm) },
+ onCancel = onCancel,
+ )
+ }
+}
+
+@Composable
+private fun PriceConfigContent(
+ state: AppWidgetConfigUiState,
+ onTogglePair: (TradingPair) -> Unit,
+ onSelectPeriod: (GraphPeriod) -> Unit,
+ onReset: () -> Unit,
+ onSave: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ val prefs = state.pricePreferences
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.widgets__widget__edit),
+ onBackClick = onCancel,
+ )
+
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .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)
+
+ BodySSB(
+ text = stringResource(R.string.appwidget__price__trading_pairs),
+ color = Colors.White64,
+ )
+ VerticalSpacer(8.dp)
+
+ for (pair in TradingPair.entries) {
+ ConfigToggleRow(
+ label = pair.displayName,
+ isEnabled = pair in prefs.enabledPairs,
+ onClick = { onTogglePair(pair) },
+ )
+ }
+
+ VerticalSpacer(16.dp)
+ BodySSB(
+ text = stringResource(R.string.appwidget__price__period),
+ color = Colors.White64,
+ )
+ VerticalSpacer(8.dp)
+
+ for (period in GraphPeriod.entries) {
+ ConfigToggleRow(
+ label = period.value,
+ isEnabled = period == prefs.period,
+ onClick = { onSelectPeriod(period) },
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .padding(vertical = 21.dp, horizontal = 16.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ 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 ConfigToggleRow(
+ label: String,
+ isEnabled: Boolean,
+ onClick: () -> Unit,
+) {
+ Column {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ .fillMaxWidth(),
+ ) {
+ BodySSB(
+ text = label,
+ color = Colors.White64,
+ modifier = Modifier.weight(1f),
+ )
+ IconButton(onClick = onClick) {
+ Icon(
+ painter = painterResource(R.drawable.ic_checkmark),
+ contentDescription = null,
+ tint = if (isEnabled) Colors.Brand else Colors.White50,
+ 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 000000000..75954e3a6
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt
@@ -0,0 +1,106 @@
+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.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 togglePricePair(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 }))
+ }
+ }
+
+ 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 000000000..0aedb2ed0
--- /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/GlanceSpacer.kt b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceSpacer.kt
new file mode 100644
index 000000000..a7a571ea2
--- /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 000000000..1d1c5bb17
--- /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, modifier = modifier, style = GlanceTextStyles.subtitle.withColor(color), maxLines = maxLines)
+}
+
+@Composable
+fun BodyMSB(
+ text: String,
+ modifier: GlanceModifier = GlanceModifier,
+ color: ColorProvider? = null,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ Text(text = text, modifier = modifier, style = GlanceTextStyles.bodyMSB.withColor(color), maxLines = maxLines)
+}
+
+@Composable
+fun BodySSB(
+ text: String,
+ modifier: GlanceModifier = GlanceModifier,
+ color: ColorProvider? = null,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ Text(text = text, modifier = modifier, style = GlanceTextStyles.bodySSB.withColor(color), maxLines = maxLines)
+}
+
+@Composable
+fun BodySB(
+ text: String,
+ modifier: GlanceModifier = GlanceModifier,
+ color: ColorProvider? = null,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ Text(text = text, modifier = modifier, style = GlanceTextStyles.bodySB.withColor(color), maxLines = maxLines)
+}
+
+@Composable
+fun CaptionB(
+ text: String,
+ modifier: GlanceModifier = GlanceModifier,
+ color: ColorProvider? = null,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ Text(text = text, modifier = modifier, style = GlanceTextStyles.captionB.withColor(color), maxLines = maxLines)
+}
+
+@Composable
+fun FootnoteM(
+ text: String,
+ modifier: GlanceModifier = GlanceModifier,
+ color: ColorProvider? = null,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ Text(text = text, modifier = modifier, style = GlanceTextStyles.footnoteM.withColor(color), maxLines = maxLines)
+}
+
+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 000000000..117f1fc9e
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/ui/components/GlanceWidgetScaffold.kt
@@ -0,0 +1,33 @@
+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.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
+
+@Composable
+fun GlanceWidgetScaffold(
+ onClick: Intent? = null,
+ content: @Composable () -> Unit,
+) {
+ val modifier = GlanceModifier
+ .fillMaxSize()
+ .cornerRadius(16.dp)
+ .background(GlanceColors.cardBackgroundProvider)
+ .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 000000000..e1c20dad8
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/ui/price/LineChartBitmap.kt
@@ -0,0 +1,86 @@
+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
+
+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)
+
+ 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)
+
+ 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 {
+ 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
+ val cp2x = p2.first - (p3.first - p1.first) * SMOOTHING
+ val cp2y = p2.second - (p3.second - p1.second) * SMOOTHING
+
+ 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 000000000..1b4c40b39
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceContent.kt
@@ -0,0 +1,119 @@
+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.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.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.data.dto.price.PriceWidgetData
+import to.bitkit.ui.theme.Colors
+
+@Suppress("RestrictedApi")
+@Composable
+fun PriceGlanceContent(
+ price: PriceDTO?,
+ 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)
+ putExtra(AppWidgetConfigActivity.EXTRA_WIDGET_TYPE, AppWidgetType.PRICE.name)
+ }
+
+ GlanceWidgetScaffold(onClick = configIntent) {
+ if (price == 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(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ .then(HeightModifier(Dimension.Expand)),
+ contentAlignment = Alignment.BottomStart,
+ ) {
+ 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),
+ )
+ }
+ }
+ }
+}
+
+@Suppress("RestrictedApi")
+@Composable
+private fun PriceRow(widget: PriceWidgetData) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BodySB(
+ text = widget.pair.displayName,
+ color = GlanceColors.textSecondary,
+ modifier = GlanceModifier.then(WidthModifier(Dimension.Expand)),
+ )
+ BodySB(
+ text = widget.change.formatted,
+ color = if (widget.change.isPositive) {
+ ColorProvider(day = Colors.Green, night = Colors.Green)
+ } else {
+ ColorProvider(day = Colors.Red, night = Colors.Red)
+ },
+ )
+ HorizontalSpacer(16.dp)
+ BodySB(text = "${widget.pair.symbol}${widget.price}")
+ }
+}
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 000000000..880d28001
--- /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 000000000..6f2781dca
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceWidget.kt
@@ -0,0 +1,80 @@
+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.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+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.data.dto.price.PriceDTO
+import to.bitkit.ui.theme.Colors
+
+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),
+ )
+
+ 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 chartBitmap = remember(price, entry.pricePreferences) {
+ buildChartBitmap(price, entry)
+ }
+
+ PriceGlanceContent(
+ price = price,
+ 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
+
+ val lineColor = if (chartData.change.isPositive) {
+ Colors.Green.toArgb()
+ } else {
+ Colors.Red.toArgb()
+ }
+
+ return renderLineChartBitmap(
+ values = chartData.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 000000000..427bb161d
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceColors.kt
@@ -0,0 +1,11 @@
+package to.bitkit.appwidget.ui.theme
+
+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
new file mode 100644
index 000000000..512241bc0
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/ui/theme/GlanceTextStyles.kt
@@ -0,0 +1,14 @@
+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 footnoteM = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium, color = GlanceColors.textSecondary)
+}
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 000000000..62b3b08fb
--- /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 3b859bad0..159bde54c 100644
--- a/app/src/main/java/to/bitkit/data/widgets/PriceService.kt
+++ b/app/src/main/java/to/bitkit/data/widgets/PriceService.kt
@@ -38,9 +38,12 @@ class PriceService @Inject constructor(
override val refreshInterval = 1.minutes
private val sourceLabel = "Bitfinex.com"
- override suspend fun fetchData(): Result = runCatching {
+ override suspend fun fetchData(): Result {
val period = widgetsStore.data.first().pricePreferences.period ?: GraphPeriod.ONE_DAY
+ return fetchData(period)
+ }
+ 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) }
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 1992e08cd..629b84842 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/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt
index fe9a86efc..6572848a8 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/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt
index 199e2ff3e..dde2ee0a4 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 4349eced4..527dff93d 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/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt
index 74e1a2f3a..2885defad 100644
--- a/app/src/main/java/to/bitkit/services/LightningService.kt
+++ b/app/src/main/java/to/bitkit/services/LightningService.kt
@@ -49,8 +49,8 @@ import to.bitkit.env.Env
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.ext.uByteList
import to.bitkit.ext.uri
-import to.bitkit.models.msatFloorOf
import to.bitkit.models.OpenChannelResult
+import to.bitkit.models.msatFloorOf
import to.bitkit.models.toAddressType
import to.bitkit.utils.AppError
import to.bitkit.utils.LdkError
diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt
index 1b6f70840..43b87cd91 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/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt
index 0b93407ac..a41243741 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 89f42eb0f..4c1913954 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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 8ae385658..3b44affbd 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -91,7 +91,6 @@ import to.bitkit.ext.toUserMessage
import to.bitkit.ext.totalValue
import to.bitkit.ext.watchUntil
import to.bitkit.models.FeeRate
-import to.bitkit.models.msatFloorOf
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
@@ -100,6 +99,7 @@ import to.bitkit.models.Suggestion
import to.bitkit.models.Toast
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.TransferType
+import to.bitkit.models.msatFloorOf
import to.bitkit.models.safe
import to.bitkit.models.toActivityFilter
import to.bitkit.models.toLdkNetwork
@@ -2463,6 +2463,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/chart_preview.xml b/app/src/main/res/drawable/chart_preview.xml
new file mode 100644
index 000000000..ee3b28e75
--- /dev/null
+++ b/app/src/main/res/drawable/chart_preview.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
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 000000000..69637f743
--- /dev/null
+++ b/app/src/main/res/layout/appwidget_preview_price.xml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..b294f56e7
--- /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 f30d09dd8..97308bd87 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/strings.xml b/app/src/main/res/values/strings.xml
index 20c91cd2b..7872f0e4d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,9 @@
+ Loading…
+ Bitcoin price tracker
+ Time period
+ Trading pairs
Store your bitcoin
Back up
Buy some bitcoin
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 000000000..28be39405
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_price.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7b221650f..2ca0478f6 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" }