Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
9f86a9a
chore: add glance dependency
jvsena42 Apr 3, 2026
55b19eb
chore: data models
jvsena42 Apr 3, 2026
e909473
chore: independent data store for home widgets and cached data
jvsena42 Apr 3, 2026
b3d1235
feat: create repository reusing existing services
jvsena42 Apr 3, 2026
a3397b1
feat: refresh data with work manager
jvsena42 Apr 3, 2026
2bc681f
feat: data serializer
jvsena42 Apr 3, 2026
8112f12
feat: ui
jvsena42 Apr 3, 2026
01db527
feat: config
jvsena42 Apr 3, 2026
b19db43
feat: resources
jvsena42 Apr 3, 2026
32b14d2
feat: overload with GraphPeriod
jvsena42 Apr 3, 2026
4dca8ce
feat: manifest settings
jvsena42 Apr 3, 2026
fac5d41
refactor: remove duplicated strings
jvsena42 Apr 6, 2026
1c29a88
refactor: reuse existing colors
jvsena42 Apr 6, 2026
ae32258
refactor: por the existing design system to Glance
jvsena42 Apr 6, 2026
62be2c2
chore: lint
jvsena42 Apr 6, 2026
d1f4357
Merge branch 'master' into feat/system-widgets-foundation
jvsena42 Apr 9, 2026
9ebaffe
refactor: remove boilerplate
jvsena42 Apr 9, 2026
abf1cfd
feat: port price widget to Glance
jvsena42 Apr 9, 2026
8c23053
feat: update widget instantly instead of enqueue
jvsena42 Apr 10, 2026
50d6372
refactor: implement spacer components
jvsena42 Apr 10, 2026
f7a37e7
chore: lint
jvsena42 Apr 10, 2026
4bc89ea
fix: horizontal spacer
jvsena42 Apr 10, 2026
f9368ff
refactor: remove source
jvsena42 Apr 10, 2026
705c754
fix: move chart to bottom
jvsena42 Apr 10, 2026
8aa87b0
refactor: remove context parameter
jvsena42 Apr 10, 2026
0c9a97c
feat: corner radius
jvsena42 Apr 10, 2026
d3699fc
feat: make the widget responsive
jvsena42 Apr 10, 2026
775cf0e
fix: push value to the left
jvsena42 Apr 10, 2026
4656e8a
feat: make chart expandable
jvsena42 Apr 10, 2026
6183158
feat: remove click event
jvsena42 Apr 10, 2026
e85dcd0
feat: fill height with the chart
jvsena42 Apr 10, 2026
940c027
feat: padding
jvsena42 Apr 10, 2026
2a6df91
Merge branch 'master' into feat/system-widgets-foundation
jvsena42 Apr 17, 2026
21c7378
Merge branch 'master' into feat/system-widgets-foundation
jvsena42 Apr 20, 2026
362eda4
feat: display period
jvsena42 Apr 20, 2026
4b46176
feat: edit widget on click
jvsena42 Apr 20, 2026
c0a328f
feat: remove header from preview
jvsena42 Apr 21, 2026
0e0cdee
feat: launch AppWidgetConfigActivity on a new task to don't return no…
jvsena42 Apr 21, 2026
2762e5c
fix: match glance text size with Compose
jvsena42 Apr 21, 2026
9ae6fa6
refactor: extract text components
jvsena42 Apr 21, 2026
a11f6e3
feat: make the chart smooth
jvsena42 Apr 21, 2026
757f370
refactor: rename bitmap component
jvsena42 Apr 21, 2026
75b210e
fix: reset enabled state
jvsena42 Apr 21, 2026
b9dad83
refactor: code cleanup
jvsena42 Apr 21, 2026
bec35f7
refactor: stability annotation
jvsena42 Apr 21, 2026
dc02330
refactor: move updateAll to activity level
jvsena42 Apr 21, 2026
3258330
fear: chart preview mockup
jvsena42 Apr 21, 2026
3d7c040
Merge branch 'master' into feat/system-widgets-foundation
jvsena42 Apr 21, 2026
1de89d4
Merge branch 'master' into feat/system-widgets-foundation
jvsena42 Apr 22, 2026
3352dd5
fix: drop singleInstance on widget config activity
jvsena42 Apr 22, 2026
74e367c
fix: cache price data per graph period in widget worker
jvsena42 Apr 22, 2026
a84f973
refactor: move companion object to top
jvsena42 Apr 22, 2026
76d5bd4
fix: check all widget types before cancelling refresh worker
jvsena42 Apr 22, 2026
14817e4
fix: clear widget preferences on instance deletion
jvsena42 Apr 22, 2026
2667245
refactor: replace with runCatching
jvsena42 Apr 22, 2026
ee6ecc2
doc: changelog entry
jvsena42 Apr 22, 2026
b806ac2
chore: lint
jvsena42 Apr 22, 2026
83a6955
chore: lint
jvsena42 Apr 22, 2026
fcd4ba5
chore: lint
jvsena42 Apr 22, 2026
a335c67
refactor: use Hilt EntryPoint for AppWidgetPreferencesStore
jvsena42 Apr 22, 2026
1cf5f37
Merge branch 'master' into feat/system-widgets-foundation
jvsena42 Apr 27, 2026
4a5061f
fix: warp get data in runCatching
jvsena42 Apr 27, 2026
d756a14
chore: lint
jvsena42 Apr 27, 2026
c082153
chore: lint
jvsena42 Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Polish Terms of Use screen padding to match iOS #903

### Added
- Home screen widgets foundation with Glance, including price widget as the first implementation #895

## [2.2.0] - 2026-04-07

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,33 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>

<!-- AppWidget Config Activity -->
<activity
android:name=".appwidget.config.AppWidgetConfigActivity"
android:exported="true"
android:excludeFromRecents="true"
android:screenOrientation="portrait"
android:taskAffinity=""
android:theme="@style/Theme.App">
Comment thread
jvsena42 marked this conversation as resolved.
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>

<!-- Price Widget -->
<receiver
android:name=".appwidget.ui.price.PriceGlanceReceiver"
android:exported="true"
android:label="@string/widgets__price__name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_price" />
</receiver>

</application>

</manifest>
21 changes: 21 additions & 0 deletions app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt
Original file line number Diff line number Diff line change
@@ -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<PriceDTO> =
withContext(ioDispatcher) {
priceService.fetchData(period)
}
}
82 changes: 82 additions & 0 deletions app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt
Original file line number Diff line number Diff line change
@@ -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<AppWidgetData> 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<AppWidgetData> = 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<AppWidgetType> =
store.data.first().entries.map { it.type }.toSet()

suspend fun getActivePricePeriods(): Set<GraphPeriod> =
store.data.first().entries
.filter { it.type == AppWidgetType.PRICE }
.map { it.pricePreferences.period }
.toSet()

fun hasWidgetsOfType(type: AppWidgetType): Flow<Boolean> =
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)) }
}
}
92 changes: 92 additions & 0 deletions app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt
Original file line number Diff line number Diff line change
@@ -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<AppWidgetRefreshWorker>(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)
}
}
Comment thread
jvsena42 marked this conversation as resolved.

private fun receiverClassFor(type: AppWidgetType): Class<out GlanceAppWidgetReceiver> = 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()
}
}
Original file line number Diff line number Diff line change
@@ -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() },
)
}
}
}
}
Loading
Loading