Skip to content

Commit fa9474a

Browse files
authored
Merge branch 'master' into fix/currency-widget-consistency-881
2 parents ad709ef + d60467a commit fa9474a

54 files changed

Lines changed: 1821 additions & 490 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
198198
- ALWAYS use `remember` for expensive Compose computations
199199
- ALWAYS declare `modifier: Modifier = Modifier,` as the FIRST optional parameter in composable declarations
200200
- ALWAYS pass `modifier = ...` as the LAST argument in composable calls
201-
- ALWAYS add trailing commas in multi-line declarations; NEVER add a trailing comma to `modifier = ...` at call sites
201+
- ALWAYS add trailing commas in multi-line declarations, EXCEPT after a `modifier = ...` last argument — never add a trailing comma there, whether the modifier is a single call (`modifier = Modifier.weight(1f)`) or a chain (`modifier = Modifier.fillMaxWidth().testTag("foo")`)
202202
- ALWAYS use `navController.navigateTo(route)` for simple navigation; NEVER use raw `navController.navigate(route)``navigateTo` prevents duplicate destinations
203203
- ALWAYS prefer `VerticalSpacer`, `HorizontalSpacer`, `FillHeight` and `FillWidth` over `Spacer` when applicable
204204
- PREFER declaring small dependant classes, constants, interfaces or top-level functions in the same file with the core class where these are used

app/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ dependencies {
286286
// WorkManager
287287
implementation(libs.hilt.work)
288288
implementation(libs.work.runtime.ktx)
289+
// Glance - AppWidgets
290+
implementation(libs.glance.appwidget)
291+
implementation(libs.glance.material3)
289292
// Ktor - Networking
290293
implementation(libs.ktor.client.core)
291294
implementation(libs.ktor.client.okhttp)

app/src/main/AndroidManifest.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,33 @@
177177
android:name="android.support.FILE_PROVIDER_PATHS"
178178
android:resource="@xml/provider_paths" />
179179
</provider>
180+
181+
<!-- AppWidget Config Activity -->
182+
<activity
183+
android:name=".appwidget.config.AppWidgetConfigActivity"
184+
android:exported="true"
185+
android:excludeFromRecents="true"
186+
android:screenOrientation="portrait"
187+
android:taskAffinity=""
188+
android:theme="@style/Theme.App">
189+
<intent-filter>
190+
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
191+
</intent-filter>
192+
</activity>
193+
194+
<!-- Price Widget -->
195+
<receiver
196+
android:name=".appwidget.ui.price.PriceGlanceReceiver"
197+
android:exported="true"
198+
android:label="@string/widgets__price__name">
199+
<intent-filter>
200+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
201+
</intent-filter>
202+
<meta-data
203+
android:name="android.appwidget.provider"
204+
android:resource="@xml/appwidget_info_price" />
205+
</receiver>
206+
180207
</application>
181208

182209
</manifest>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package to.bitkit.appwidget
2+
3+
import kotlinx.coroutines.CoroutineDispatcher
4+
import kotlinx.coroutines.withContext
5+
import to.bitkit.data.dto.price.GraphPeriod
6+
import to.bitkit.data.dto.price.PriceDTO
7+
import to.bitkit.data.widgets.PriceService
8+
import to.bitkit.di.IoDispatcher
9+
import javax.inject.Inject
10+
import javax.inject.Singleton
11+
12+
@Singleton
13+
class AppWidgetDataRepository @Inject constructor(
14+
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
15+
private val priceService: PriceService,
16+
) {
17+
suspend fun fetchPriceData(period: GraphPeriod = GraphPeriod.ONE_DAY): Result<PriceDTO> =
18+
withContext(ioDispatcher) {
19+
priceService.fetchData(period)
20+
}
21+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package to.bitkit.appwidget
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.dataStore
6+
import dagger.hilt.EntryPoint
7+
import dagger.hilt.InstallIn
8+
import dagger.hilt.android.qualifiers.ApplicationContext
9+
import dagger.hilt.components.SingletonComponent
10+
import kotlinx.coroutines.flow.Flow
11+
import kotlinx.coroutines.flow.first
12+
import kotlinx.coroutines.flow.map
13+
import to.bitkit.appwidget.model.AppWidgetData
14+
import to.bitkit.appwidget.model.AppWidgetEntry
15+
import to.bitkit.appwidget.model.AppWidgetType
16+
import to.bitkit.data.dto.price.GraphPeriod
17+
import to.bitkit.data.dto.price.PriceDTO
18+
import to.bitkit.data.serializers.AppWidgetDataSerializer
19+
import javax.inject.Inject
20+
import javax.inject.Singleton
21+
22+
private val Context.appWidgetDataStore: DataStore<AppWidgetData> by dataStore(
23+
fileName = "appwidget_data.json",
24+
serializer = AppWidgetDataSerializer,
25+
)
26+
27+
@EntryPoint
28+
@InstallIn(SingletonComponent::class)
29+
interface AppWidgetEntryPoint {
30+
fun appWidgetPreferencesStore(): AppWidgetPreferencesStore
31+
}
32+
33+
@Singleton
34+
class AppWidgetPreferencesStore @Inject constructor(
35+
@ApplicationContext private val context: Context,
36+
) {
37+
private val store = context.appWidgetDataStore
38+
39+
val data: Flow<AppWidgetData> = store.data
40+
41+
suspend fun registerWidget(appWidgetId: Int, type: AppWidgetType) {
42+
store.updateData { data ->
43+
if (data.entries.any { it.appWidgetId == appWidgetId }) return@updateData data
44+
data.copy(entries = data.entries + AppWidgetEntry(appWidgetId = appWidgetId, type = type))
45+
}
46+
}
47+
48+
suspend fun unregisterWidget(appWidgetId: Int) {
49+
store.updateData { data ->
50+
data.copy(entries = data.entries.filter { it.appWidgetId != appWidgetId })
51+
}
52+
}
53+
54+
suspend fun getEntry(appWidgetId: Int): AppWidgetEntry? =
55+
store.data.first().entries.find { it.appWidgetId == appWidgetId }
56+
57+
suspend fun updateEntry(appWidgetId: Int, transform: (AppWidgetEntry) -> AppWidgetEntry) {
58+
store.updateData { data ->
59+
data.copy(
60+
entries = data.entries.map {
61+
if (it.appWidgetId == appWidgetId) transform(it) else it
62+
},
63+
)
64+
}
65+
}
66+
67+
suspend fun getActiveWidgetTypes(): Set<AppWidgetType> =
68+
store.data.first().entries.map { it.type }.toSet()
69+
70+
suspend fun getActivePricePeriods(): Set<GraphPeriod> =
71+
store.data.first().entries
72+
.filter { it.type == AppWidgetType.PRICE }
73+
.map { it.pricePreferences.period }
74+
.toSet()
75+
76+
fun hasWidgetsOfType(type: AppWidgetType): Flow<Boolean> =
77+
data.map { it.entries.any { entry -> entry.type == type } }
78+
79+
suspend fun cachePriceData(period: GraphPeriod, price: PriceDTO) {
80+
store.updateData { it.copy(cachedPrices = it.cachedPrices + (period to price)) }
81+
}
82+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package to.bitkit.appwidget
2+
3+
import android.appwidget.AppWidgetManager
4+
import android.content.ComponentName
5+
import android.content.Context
6+
import androidx.glance.appwidget.GlanceAppWidgetReceiver
7+
import androidx.glance.appwidget.updateAll
8+
import androidx.hilt.work.HiltWorker
9+
import androidx.work.Constraints
10+
import androidx.work.CoroutineWorker
11+
import androidx.work.ExistingPeriodicWorkPolicy
12+
import androidx.work.NetworkType
13+
import androidx.work.PeriodicWorkRequestBuilder
14+
import androidx.work.WorkManager
15+
import androidx.work.WorkerParameters
16+
import dagger.assisted.Assisted
17+
import dagger.assisted.AssistedInject
18+
import to.bitkit.appwidget.model.AppWidgetType
19+
import to.bitkit.appwidget.ui.price.PriceGlanceReceiver
20+
import to.bitkit.appwidget.ui.price.PriceGlanceWidget
21+
import to.bitkit.utils.Logger
22+
import kotlin.time.Duration.Companion.minutes
23+
import kotlin.time.toJavaDuration
24+
25+
@HiltWorker
26+
class AppWidgetRefreshWorker @AssistedInject constructor(
27+
@Assisted private val appContext: Context,
28+
@Assisted workerParams: WorkerParameters,
29+
private val dataRepository: AppWidgetDataRepository,
30+
private val preferencesStore: AppWidgetPreferencesStore,
31+
) : CoroutineWorker(appContext, workerParams) {
32+
33+
companion object {
34+
private const val TAG = "AppWidgetRefreshWorker"
35+
private const val WORK_NAME = "appwidget_refresh"
36+
37+
fun enqueue(context: Context) {
38+
val constraints = Constraints.Builder()
39+
.setRequiredNetworkType(NetworkType.CONNECTED)
40+
.build()
41+
42+
val request = PeriodicWorkRequestBuilder<AppWidgetRefreshWorker>(15.minutes.toJavaDuration())
43+
.setConstraints(constraints)
44+
.build()
45+
46+
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
47+
WORK_NAME,
48+
ExistingPeriodicWorkPolicy.KEEP,
49+
request,
50+
)
51+
}
52+
53+
fun cancelIfNoWidgets(context: Context) {
54+
val manager = AppWidgetManager.getInstance(context)
55+
val hasAny = AppWidgetType.entries.any { type ->
56+
manager.getAppWidgetIds(ComponentName(context, receiverClassFor(type))).isNotEmpty()
57+
}
58+
if (!hasAny) {
59+
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
60+
}
61+
}
62+
63+
private fun receiverClassFor(type: AppWidgetType): Class<out GlanceAppWidgetReceiver> = when (type) {
64+
AppWidgetType.PRICE -> PriceGlanceReceiver::class.java
65+
}
66+
}
67+
68+
override suspend fun doWork(): Result {
69+
val activeTypes = preferencesStore.getActiveWidgetTypes()
70+
if (activeTypes.isEmpty()) return Result.success()
71+
72+
Logger.debug("Refreshing data for widget types: '$activeTypes'", context = TAG)
73+
74+
for (type in activeTypes) {
75+
when (type) {
76+
AppWidgetType.PRICE -> {
77+
val periods = preferencesStore.getActivePricePeriods()
78+
periods.forEach { period ->
79+
dataRepository.fetchPriceData(period)
80+
.onSuccess { preferencesStore.cachePriceData(period, it) }
81+
.onFailure {
82+
Logger.warn("Failed to refresh price for '$period'", it, context = TAG)
83+
}
84+
}
85+
PriceGlanceWidget().updateAll(appContext)
86+
}
87+
}
88+
}
89+
90+
return Result.success()
91+
}
92+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package to.bitkit.appwidget.config
2+
3+
import android.app.Activity
4+
import android.appwidget.AppWidgetManager
5+
import android.content.Intent
6+
import android.os.Bundle
7+
import androidx.activity.ComponentActivity
8+
import androidx.activity.compose.setContent
9+
import androidx.activity.viewModels
10+
import androidx.glance.appwidget.updateAll
11+
import dagger.hilt.android.AndroidEntryPoint
12+
import to.bitkit.appwidget.AppWidgetRefreshWorker
13+
import to.bitkit.appwidget.model.AppWidgetType
14+
import to.bitkit.appwidget.ui.price.PriceGlanceWidget
15+
import to.bitkit.ui.theme.AppThemeSurface
16+
17+
@AndroidEntryPoint
18+
class AppWidgetConfigActivity : ComponentActivity() {
19+
20+
companion object {
21+
const val EXTRA_WIDGET_TYPE = "extra_widget_type"
22+
}
23+
24+
private val viewModel: AppWidgetConfigViewModel by viewModels()
25+
26+
override fun onCreate(savedInstanceState: Bundle?) {
27+
super.onCreate(savedInstanceState)
28+
29+
val appWidgetId = intent?.extras?.getInt(
30+
AppWidgetManager.EXTRA_APPWIDGET_ID,
31+
AppWidgetManager.INVALID_APPWIDGET_ID,
32+
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
33+
34+
setResult(RESULT_CANCELED)
35+
36+
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
37+
finish()
38+
return
39+
}
40+
41+
val typeName = intent?.getStringExtra(EXTRA_WIDGET_TYPE)
42+
val type = typeName?.let { runCatching { AppWidgetType.valueOf(it) }.getOrNull() }
43+
?: AppWidgetType.PRICE
44+
45+
if (savedInstanceState == null) viewModel.init(appWidgetId, type)
46+
47+
setContent {
48+
AppThemeSurface {
49+
AppWidgetConfigScreen(
50+
viewModel = viewModel,
51+
onConfirm = {
52+
PriceGlanceWidget().updateAll(this@AppWidgetConfigActivity)
53+
AppWidgetRefreshWorker.enqueue(this@AppWidgetConfigActivity)
54+
val result = Intent().putExtra(
55+
AppWidgetManager.EXTRA_APPWIDGET_ID,
56+
appWidgetId,
57+
)
58+
setResult(Activity.RESULT_OK, result)
59+
finish()
60+
},
61+
onCancel = { finish() },
62+
)
63+
}
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)