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" }